Compare commits
260 Commits
v2.7.4
...
upgrade-gitlab
| Author | SHA1 | Date | |
|---|---|---|---|
| ab710c2798 | |||
| 3cacae31e8 | |||
| 5df79510e2 | |||
| efc030ffe4 | |||
| 31c6ba34f7 | |||
| 03af1c5290 | |||
| ca5347f4b4 | |||
| 4b7e9eef43 | |||
| bbf064e4fc | |||
| 72c2a83e4f | |||
| 8daa9b9ddf | |||
| c888f28b2c | |||
| 456d935ae1 | |||
| 2c1eec5c0f | |||
| 542dbb5111 | |||
| 4d41251dbc | |||
| 9c610bacd2 | |||
| 94c079c109 | |||
| c04738c543 | |||
| 6fe4f7b502 | |||
| 65d4635709 | |||
| cec25b5d1e | |||
| cd0ffe0151 | |||
| f234f2f26f | |||
| 5afd1d8728 | |||
| e833099e1d | |||
| b4cf45fc95 | |||
| 3e49729349 | |||
| 9a68454996 | |||
| 9f995dfec0 | |||
| 65d975b490 | |||
| 96fda4cd56 | |||
| 2ca3468423 | |||
| 4f45409697 | |||
| 923a8ae394 | |||
| 4e8c13ba59 | |||
| 61316ccca7 | |||
| 43574215a6 | |||
| de76bb4725 | |||
| b767c68876 | |||
| a84934311a | |||
| 4810828b53 | |||
| e6ac1390d0 | |||
| 6f803d47bc | |||
| 4e815ab5e2 | |||
| a969646a56 | |||
| 1e104631e4 | |||
| 44612a750d | |||
| e31c1a4f7d | |||
| 8f5b850727 | |||
| 7a725fc3ad | |||
| 56166fd90c | |||
| c438a10efc | |||
| 7a53052d06 | |||
| b1b4438ae9 | |||
| 862ab9b370 | |||
| c1355c1e72 | |||
| e0803ee689 | |||
| 04b23241e1 | |||
| 3aaa5fd4ef | |||
| f265800a87 | |||
| 0afcda1a50 | |||
| d78d406a52 | |||
| 5999cd4b9a | |||
| 3c2fe83dc2 | |||
| 9351ff68af | |||
| 3fe2820cf0 | |||
| 166cc7ca72 | |||
| 9daccd1847 | |||
| 3e21c27749 | |||
| ed778892df | |||
| 22953596c6 | |||
| 8c41d5b56d | |||
| 4bfdb6d459 | |||
| 9d9e56208c | |||
| 5425087730 | |||
| fa7cd5f847 | |||
| 6d95d5b1a3 | |||
| f75d52d5c6 | |||
| 272410d3e9 | |||
| 63281daf2f | |||
| 4b5a433923 | |||
| abb86f161b | |||
| 626bb58a69 | |||
| c8b4c4c620 | |||
| c031d0c215 | |||
| 4f5b2fcab9 | |||
| df3878d36a | |||
| 4e78a9d7e0 | |||
| c1238ec834 | |||
| 99a7d2d735 | |||
| 19ab6eeb30 | |||
| 00d918ecaa | |||
| 474efa09cf | |||
| 5256361d8c | |||
| c0938d351f | |||
| 2cee1d795e | |||
| 9a4b93056b | |||
| 8be056324a | |||
| e45e46211b | |||
| aa608bb769 | |||
| 7d27a26665 | |||
| e9bcccfede | |||
| d349ffe37d | |||
| ac7f72b62b | |||
| 968bebadf6 | |||
| 2bfdadd301 | |||
| 36686b945c | |||
| 4e52adc7f0 | |||
| 21ca8d4d17 | |||
| 3e198177da | |||
| 7ba6dacc5c | |||
| c97bdd412f | |||
| 4eaf59113f | |||
| 082a706f7f | |||
| 8668902dd1 | |||
| 8bc3ba3e1c | |||
| 2fdbde7fde | |||
| 7d7f20da25 | |||
| e5128ea97e | |||
| 4f2374178c | |||
| 125464ed72 | |||
| 69e2c6bc7d | |||
| 7c9810ea3b | |||
| c601a212f6 | |||
| 02734f28ba | |||
| 3d4eec61fe | |||
| 8a777bdd0f | |||
| e2af45aee4 | |||
| befe53a722 | |||
| 241d703e7f | |||
| c432d380dd | |||
| 457abed9f9 | |||
| 5fc8afcaaf | |||
| 7bf0bda689 | |||
| d9f51d047d | |||
| dc5631f12b | |||
| 3f9d5bdc3d | |||
| 64e18014c3 | |||
| e9226713e8 | |||
| 6a5e644798 | |||
| 0b0be7c1b6 | |||
| 484346ffcc | |||
| 5b3acbfcb5 | |||
| 2288dd90d6 | |||
| af05357a62 | |||
| 64808a0eac | |||
| 2ead4fb31c | |||
| b60dfbe970 | |||
| ee8bb8d8a0 | |||
| 5f3098477e | |||
| 4c79a76e94 | |||
| 1516761fc8 | |||
| 52b1c1152b | |||
| ab4bbffa5b | |||
| e7314e8926 | |||
| 2666eaf8fc | |||
| 8262f8099e | |||
| cbc5c736f4 | |||
| ac71dd88a3 | |||
| c5e5dfb8ae | |||
| 6ae880501e | |||
| fd547dfe42 | |||
| 436dc7920a | |||
| 7a8cf63623 | |||
| a6aefab55b | |||
| 5e5ee73046 | |||
| 8362c88791 | |||
| 340a048e8b | |||
| 4b2fc84402 | |||
| 2673348c2f | |||
| 7132eb3435 | |||
| 473b02ce5c | |||
| 862d9ddb6d | |||
| 33b9345883 | |||
| e169a97577 | |||
| 4eddf80724 | |||
| 99f182be06 | |||
| cf785cebcc | |||
| 7ff4c32d16 | |||
| 75bf2d608f | |||
| f950198f9d | |||
| 2a2201fe56 | |||
| a4903a95be | |||
| 8c041095ab | |||
| 63dfdd133c | |||
| b2be6f96c9 | |||
| 1dfc906802 | |||
| d57313ae2c | |||
| c125bcb1ca | |||
| 7dd9fde7ce | |||
| f2f7d59577 | |||
| 7459e457bf | |||
| 145f98b53a | |||
| 6c58ea576e | |||
| 580ef30c8f | |||
| 94e9af6b2a | |||
| 3fb05a604f | |||
| 9b76ba19a8 | |||
| 1e7dd5dfd8 | |||
| d6dec730d8 | |||
| 0ba28f3f91 | |||
| 55936e9366 | |||
| 6ecad4783f | |||
| 5759d08473 | |||
| 5048de80f0 | |||
| 97a437d059 | |||
| cfb28ffdc0 | |||
| ae9ef62f39 | |||
| 69feb7214a | |||
| e95da82f5a | |||
| 7c5f9befb4 | |||
| 26a8d0c1c7 | |||
| 833815c71d | |||
| 31287b9b27 | |||
| 28f5b553a2 | |||
| b33f173670 | |||
| d8c6ee167c | |||
| e288cb2771 | |||
| 5f2a6ebc2b | |||
| cdc37c304a | |||
| 60e4d99b57 | |||
| 8229ffb674 | |||
| 9b944da896 | |||
| 5b37a6b04b | |||
| 9f18062d43 | |||
| 1055f28524 | |||
| 7b0021c1a8 | |||
| ba997449aa | |||
| ca2f0205c4 | |||
| 058525fe37 | |||
| 686ee31f8a | |||
| 767f235f94 | |||
| d5a2c66746 | |||
| f2ff083b8e | |||
| 8c45f25f33 | |||
| f85cbfa9c8 | |||
| 71a3dad213 | |||
| 72e0535958 | |||
| 4f2d1c3a2a | |||
| 8e99cf7c93 | |||
| 2bb7f38603 | |||
| 0fe4449870 | |||
| 7c5fb2297c | |||
| f4a811fbd3 | |||
| bb3726bb87 | |||
| 333c8fe704 | |||
| 83213ce83f | |||
| 69718599ac | |||
| 0255957dd7 | |||
| 69b4b85cd9 | |||
| a9b5be7ff4 | |||
| 1b46056e7d | |||
| 039d79b3c2 | |||
| 66b8aca399 | |||
| 41c413e178 | |||
| d5f8720c4d | |||
| e6eb9d79e3 | |||
| b90d1738a9 | |||
| f9e66dee9e |
+6
-6
@@ -44,12 +44,12 @@
|
||||
description: Feature request proposals in the RFC format
|
||||
color: '#D621C3'
|
||||
aliases: ['area/RFC']
|
||||
- name: backport:release/v2.4.x
|
||||
description: To be backported to release/v2.4.x
|
||||
color: '#ffd700'
|
||||
- name: backport:release/v2.5.x
|
||||
description: To be backported to release/v2.5.x
|
||||
color: '#ffd700'
|
||||
- name: backport:release/v2.6.x
|
||||
description: To be backported to release/v2.6.x
|
||||
color: '#ffd700'
|
||||
- name: backport:release/v2.7.x
|
||||
description: To be backported to release/v2.7.x
|
||||
color: '#ffd700'
|
||||
- name: backport:release/v2.8.x
|
||||
description: To be backported to release/v2.8.x
|
||||
color: '#ffd700'
|
||||
|
||||
@@ -23,7 +23,7 @@ amd when it finds a new controller version, the workflow performs the following
|
||||
- Updates the controller API package version in `go.mod`.
|
||||
- Patches the controller CRDs version in the `manifests/crds` overlay.
|
||||
- Patches the controller Deployment version in `manifests/bases` overlay.
|
||||
- Opens a Pull Request against the `main` branch.
|
||||
- Opens a Pull Request against the checked out branch.
|
||||
- Triggers the e2e test suite to run for the opened PR.
|
||||
|
||||
|
||||
|
||||
@@ -24,6 +24,44 @@ jobs:
|
||||
name: action on ${{ matrix.version }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- name: Setup flux
|
||||
uses: ./action
|
||||
|
||||
plugins:
|
||||
runs-on: ubuntu-latest
|
||||
name: action plugins on ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache-dependency-path: go.sum
|
||||
- name: Build flux
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p cmd/flux/manifests "$RUNNER_TEMP/flux-bin"
|
||||
printf "apiVersion: v1\nkind: List\nitems: []\n" > cmd/flux/manifests/dummy.yaml
|
||||
CGO_ENABLED=0 go build -ldflags="-s -w -X main.VERSION=0.0.0-pr" -o "$RUNNER_TEMP/flux-bin/flux" ./cmd/flux
|
||||
- name: Setup flux with plugin
|
||||
id: setup
|
||||
uses: ./action
|
||||
with:
|
||||
version: 0.0.0-pr
|
||||
bindir: ${{ runner.temp }}/flux-bin
|
||||
plugins: |
|
||||
mirror@sha256:91a1e04c2015ee66b1633e362cdb6d4550891b91bf3391a83414b9e9912e53f1
|
||||
- name: Assert plugin installation
|
||||
shell: bash
|
||||
env:
|
||||
ACTION_PLUGIN_DIR: ${{ steps.setup.outputs['plugin-dir'] }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -n "${FLUXCD_PLUGINS:-}"
|
||||
test "$ACTION_PLUGIN_DIR" = "$FLUXCD_PLUGINS"
|
||||
test -x "$FLUXCD_PLUGINS/flux-mirror"
|
||||
flux plugin list
|
||||
flux mirror --help
|
||||
|
||||
@@ -8,6 +8,6 @@ jobs:
|
||||
permissions:
|
||||
contents: write # for reading and creating branches.
|
||||
pull-requests: write # for creating pull requests against release branches.
|
||||
uses: fluxcd/gha-workflows/.github/workflows/backport.yaml@v0.4.0
|
||||
uses: fluxcd/gha-workflows/.github/workflows/backport.yaml@v0.11.0
|
||||
secrets:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
github-token: ${{ secrets.BOT_GITHUB_TOKEN }}
|
||||
|
||||
@@ -3,13 +3,13 @@ name: conformance
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [ 'main', 'update-components', 'release/**', 'conform*' ]
|
||||
branches: [ 'main', 'update-components-**', 'release/**', 'conform*' ]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
GO_VERSION: 1.25.x
|
||||
GO_VERSION: 1.26.x
|
||||
|
||||
jobs:
|
||||
conform-kubernetes:
|
||||
@@ -19,13 +19,13 @@ jobs:
|
||||
matrix:
|
||||
# Keep this list up-to-date with https://endoflife.date/kubernetes
|
||||
# Build images with https://github.com/fluxcd/flux-benchmark/actions/workflows/build-kind.yaml
|
||||
KUBERNETES_VERSION: [1.32.1, 1.33.0, 1.34.1]
|
||||
KUBERNETES_VERSION: [1.34.1, 1.35.2, 1.36.1]
|
||||
fail-fast: false
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
cache-dependency-path: |
|
||||
@@ -40,9 +40,9 @@ jobs:
|
||||
run: |
|
||||
make build
|
||||
- name: Setup Kubernetes
|
||||
uses: helm/kind-action@a1b0e391336a6ee6713a0583f8c6240d70863de3 # v1.12.0
|
||||
uses: helm/kind-action@ef37e7f390d99f746eb8b610417061a60e82a6cc # v1.14.0
|
||||
with:
|
||||
version: v0.30.0
|
||||
version: v0.32.0
|
||||
cluster_name: ${{ steps.prep.outputs.CLUSTER }}
|
||||
node_image: ghcr.io/fluxcd/kindest/node:v${{ matrix.KUBERNETES_VERSION }}-arm64
|
||||
- name: Run e2e tests
|
||||
@@ -76,13 +76,13 @@ jobs:
|
||||
matrix:
|
||||
# Keep this list up-to-date with https://endoflife.date/kubernetes
|
||||
# Available versions can be found with "replicated cluster versions"
|
||||
K3S_VERSION: [ 1.32.8, 1.33.4 ]
|
||||
K3S_VERSION: [ 1.34.8, 1.35.5, 1.36.1 ]
|
||||
fail-fast: false
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
cache-dependency-path: |
|
||||
@@ -97,7 +97,7 @@ jobs:
|
||||
KUBECONFIG_PATH="$(git rev-parse --show-toplevel)/bin/kubeconfig.yaml"
|
||||
echo "kubeconfig-path=${KUBECONFIG_PATH}" >> $GITHUB_OUTPUT
|
||||
- name: Setup Kustomize
|
||||
uses: fluxcd/pkg/actions/kustomize@bf02f0a2d612cc07e0892166369fa8f63246aabb # main
|
||||
uses: fluxcd/pkg/actions/kustomize@5a7f3ce0de742b6c561a50f90940d81cf6fc698d # main
|
||||
- name: Build
|
||||
run: make build-dev
|
||||
- name: Create repository
|
||||
@@ -107,7 +107,7 @@ jobs:
|
||||
GITHUB_TOKEN: ${{ secrets.GITPROVIDER_BOT_TOKEN }}
|
||||
- name: Create cluster
|
||||
id: create-cluster
|
||||
uses: replicatedhq/replicated-actions/create-cluster@49b440dabd7e0e868cbbabda5cfc0d8332a279fa # v1.19.0
|
||||
uses: replicatedhq/replicated-actions/create-cluster@6803131db735f7cc067de88fa14237c7462b247a # v1.27.0
|
||||
with:
|
||||
api-token: ${{ secrets.REPLICATED_API_TOKEN }}
|
||||
kubernetes-distribution: "k3s"
|
||||
@@ -120,8 +120,7 @@ jobs:
|
||||
run: TEST_KUBECONFIG=${{ steps.prep.outputs.kubeconfig-path }} make e2e
|
||||
- name: Run flux bootstrap
|
||||
run: |
|
||||
./bin/flux bootstrap git --manifests ./manifests/install/ \
|
||||
--components-extra=image-reflector-controller,image-automation-controller \
|
||||
./bin/flux bootstrap git --manifests ./manifests/test/ \
|
||||
--url=https://github.com/fluxcd-testing/${{ steps.prep.outputs.cluster }} \
|
||||
--branch=main \
|
||||
--path=clusters/k3s \
|
||||
@@ -151,7 +150,7 @@ jobs:
|
||||
kubectl delete ns flux-system --wait
|
||||
- name: Delete cluster
|
||||
if: ${{ always() }}
|
||||
uses: replicatedhq/replicated-actions/remove-cluster@49b440dabd7e0e868cbbabda5cfc0d8332a279fa # v1.19.0
|
||||
uses: replicatedhq/replicated-actions/remove-cluster@6803131db735f7cc067de88fa14237c7462b247a # v1.27.0
|
||||
continue-on-error: true
|
||||
with:
|
||||
api-token: ${{ secrets.REPLICATED_API_TOKEN }}
|
||||
@@ -169,13 +168,13 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
# Keep this list up-to-date with https://endoflife.date/red-hat-openshift
|
||||
OPENSHIFT_VERSION: [ 4.19.0-okd ]
|
||||
OPENSHIFT_VERSION: [ 4.20.0-okd, 4.21.0-okd ]
|
||||
fail-fast: false
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
cache-dependency-path: |
|
||||
@@ -190,7 +189,7 @@ jobs:
|
||||
KUBECONFIG_PATH="$(git rev-parse --show-toplevel)/bin/kubeconfig.yaml"
|
||||
echo "kubeconfig-path=${KUBECONFIG_PATH}" >> $GITHUB_OUTPUT
|
||||
- name: Setup Kustomize
|
||||
uses: fluxcd/pkg/actions/kustomize@bf02f0a2d612cc07e0892166369fa8f63246aabb # main
|
||||
uses: fluxcd/pkg/actions/kustomize@5a7f3ce0de742b6c561a50f90940d81cf6fc698d # main
|
||||
- name: Build
|
||||
run: make build-dev
|
||||
- name: Create repository
|
||||
@@ -200,7 +199,7 @@ jobs:
|
||||
GITHUB_TOKEN: ${{ secrets.GITPROVIDER_BOT_TOKEN }}
|
||||
- name: Create cluster
|
||||
id: create-cluster
|
||||
uses: replicatedhq/replicated-actions/create-cluster@49b440dabd7e0e868cbbabda5cfc0d8332a279fa # v1.19.0
|
||||
uses: replicatedhq/replicated-actions/create-cluster@6803131db735f7cc067de88fa14237c7462b247a # v1.27.0
|
||||
with:
|
||||
api-token: ${{ secrets.REPLICATED_API_TOKEN }}
|
||||
kubernetes-distribution: "openshift"
|
||||
@@ -212,7 +211,6 @@ jobs:
|
||||
- name: Run flux bootstrap
|
||||
run: |
|
||||
./bin/flux bootstrap git --manifests ./manifests/openshift/ \
|
||||
--components-extra=image-reflector-controller,image-automation-controller \
|
||||
--url=https://github.com/fluxcd-testing/${{ steps.prep.outputs.cluster }} \
|
||||
--branch=main \
|
||||
--path=clusters/openshift \
|
||||
@@ -242,7 +240,7 @@ jobs:
|
||||
kubectl delete ns flux-system --wait
|
||||
- name: Delete cluster
|
||||
if: ${{ always() }}
|
||||
uses: replicatedhq/replicated-actions/remove-cluster@49b440dabd7e0e868cbbabda5cfc0d8332a279fa # v1.19.0
|
||||
uses: replicatedhq/replicated-actions/remove-cluster@6803131db735f7cc067de88fa14237c7462b247a # v1.27.0
|
||||
continue-on-error: true
|
||||
with:
|
||||
api-token: ${{ secrets.REPLICATED_API_TOKEN }}
|
||||
|
||||
@@ -29,14 +29,14 @@ jobs:
|
||||
if: (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && github.actor != 'dependabot[bot]'
|
||||
steps:
|
||||
- name: CheckoutD
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
with:
|
||||
go-version: 1.25.x
|
||||
go-version: 1.26.x
|
||||
cache-dependency-path: tests/integration/go.sum
|
||||
- name: Setup Terraform
|
||||
uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd # v3.1.2
|
||||
uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1
|
||||
- name: Setup Flux CLI
|
||||
run: make build
|
||||
working-directory: ./
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
env:
|
||||
SOPS_VER: 3.7.1
|
||||
- name: Authenticate to Azure
|
||||
uses: Azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v1.4.6
|
||||
uses: Azure/login@532459ea530d8321f2fb9bb10d1e0bcf23869a43 # v1.4.6
|
||||
with:
|
||||
creds: '{"clientId":"${{ secrets.ARM_CLIENT_ID }}","clientSecret":"${{ secrets.ARM_CLIENT_SECRET }}","subscriptionId":"${{ secrets.ARM_SUBSCRIPTION_ID }}","tenantId":"${{ secrets.ARM_TENANT_ID }}"}'
|
||||
- name: Set dynamic variables in .env
|
||||
|
||||
@@ -17,27 +17,27 @@ jobs:
|
||||
if: (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && github.actor != 'dependabot[bot]'
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
with:
|
||||
go-version: 1.25.x
|
||||
go-version: 1.26.x
|
||||
cache-dependency-path: |
|
||||
**/go.sum
|
||||
**/go.mod
|
||||
- name: Setup Kubernetes
|
||||
uses: helm/kind-action@a1b0e391336a6ee6713a0583f8c6240d70863de3 # v1.12.0
|
||||
uses: helm/kind-action@ef37e7f390d99f746eb8b610417061a60e82a6cc # v1.14.0
|
||||
with:
|
||||
version: v0.30.0
|
||||
version: v0.32.0
|
||||
cluster_name: kind
|
||||
# The versions below should target the newest Kubernetes version
|
||||
# Keep this up-to-date with https://endoflife.date/kubernetes
|
||||
node_image: ghcr.io/fluxcd/kindest/node:v1.32.1-amd64
|
||||
kubectl_version: v1.32.0
|
||||
node_image: ghcr.io/fluxcd/kindest/node:v1.36.1-amd64
|
||||
kubectl_version: v1.36.0
|
||||
- name: Setup Kustomize
|
||||
uses: fluxcd/pkg/actions/kustomize@bf02f0a2d612cc07e0892166369fa8f63246aabb # main
|
||||
uses: fluxcd/pkg/actions/kustomize@5a7f3ce0de742b6c561a50f90940d81cf6fc698d # main
|
||||
- name: Setup yq
|
||||
uses: fluxcd/pkg/actions/yq@bf02f0a2d612cc07e0892166369fa8f63246aabb # main
|
||||
uses: fluxcd/pkg/actions/yq@5a7f3ce0de742b6c561a50f90940d81cf6fc698d # main
|
||||
- name: Build
|
||||
run: make build-dev
|
||||
- name: Set outputs
|
||||
@@ -51,7 +51,7 @@ jobs:
|
||||
echo "test_repo_name=$TEST_REPO_NAME" >> $GITHUB_OUTPUT
|
||||
- name: bootstrap init
|
||||
run: |
|
||||
./bin/flux bootstrap github --manifests ./manifests/install/ \
|
||||
./bin/flux bootstrap github --manifests ./manifests/test/ \
|
||||
--owner=fluxcd-testing \
|
||||
--image-pull-secret=ghcr-auth \
|
||||
--registry-creds=fluxcd:$GITHUB_TOKEN \
|
||||
@@ -66,7 +66,7 @@ jobs:
|
||||
kubectl -n flux-system get secret ghcr-auth | grep dockerconfigjson
|
||||
- name: bootstrap no-op
|
||||
run: |
|
||||
./bin/flux bootstrap github --manifests ./manifests/install/ \
|
||||
./bin/flux bootstrap github --manifests ./manifests/test/ \
|
||||
--owner=fluxcd-testing \
|
||||
--image-pull-secret=ghcr-auth \
|
||||
--repository=${{ steps.vars.outputs.test_repo_name }} \
|
||||
@@ -78,7 +78,7 @@ jobs:
|
||||
- name: bootstrap customize
|
||||
run: |
|
||||
make setup-bootstrap-patch
|
||||
./bin/flux bootstrap github --manifests ./manifests/install/ \
|
||||
./bin/flux bootstrap github --manifests ./manifests/test/ \
|
||||
--owner=fluxcd-testing \
|
||||
--repository=${{ steps.vars.outputs.test_repo_name }} \
|
||||
--branch=main \
|
||||
@@ -98,13 +98,14 @@ jobs:
|
||||
- name: test image automation
|
||||
run: |
|
||||
make setup-image-automation
|
||||
./bin/flux bootstrap github --manifests ./manifests/install/ \
|
||||
./bin/flux bootstrap github --manifests ./manifests/test/ \
|
||||
--owner=fluxcd-testing \
|
||||
--repository=${{ steps.vars.outputs.test_repo_name }} \
|
||||
--branch=main \
|
||||
--path=test-cluster \
|
||||
--read-write-key
|
||||
./bin/flux reconcile image repository podinfo
|
||||
./bin/flux reconcile image policy podinfo
|
||||
./bin/flux reconcile image update flux-system
|
||||
./bin/flux get images all
|
||||
./bin/flux -n flux-system events --for ImageUpdateAutomation/flux-system
|
||||
|
||||
@@ -29,14 +29,14 @@ jobs:
|
||||
if: (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && github.actor != 'dependabot[bot]'
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
with:
|
||||
go-version: 1.25.x
|
||||
go-version: 1.26.x
|
||||
cache-dependency-path: tests/integration/go.sum
|
||||
- name: Setup Terraform
|
||||
uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd # v3.1.2
|
||||
uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1
|
||||
- name: Setup Flux CLI
|
||||
run: make build
|
||||
working-directory: ./
|
||||
@@ -56,11 +56,11 @@ jobs:
|
||||
- name: Setup gcloud
|
||||
uses: google-github-actions/setup-gcloud@aa5489c8933f4cc7a4f7d45035b3b1440c9c10db # v3.0.1
|
||||
- name: Setup QEMU
|
||||
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0
|
||||
uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0
|
||||
- name: Setup Docker Buildx
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
- name: Log into us-central1-docker.pkg.dev
|
||||
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
with:
|
||||
registry: us-central1-docker.pkg.dev
|
||||
username: oauth2accesstoken
|
||||
|
||||
+11
-11
@@ -18,35 +18,35 @@ jobs:
|
||||
labels: ubuntu-latest-16-cores
|
||||
services:
|
||||
registry:
|
||||
image: registry:2
|
||||
image: registry:3
|
||||
ports:
|
||||
- 5000:5000
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
with:
|
||||
go-version: 1.25.x
|
||||
go-version: 1.26.x
|
||||
cache-dependency-path: |
|
||||
**/go.sum
|
||||
**/go.mod
|
||||
- name: Setup Kubernetes
|
||||
uses: helm/kind-action@a1b0e391336a6ee6713a0583f8c6240d70863de3 # v1.12.0
|
||||
uses: helm/kind-action@ef37e7f390d99f746eb8b610417061a60e82a6cc # v1.14.0
|
||||
with:
|
||||
version: v0.30.0
|
||||
version: v0.32.0
|
||||
cluster_name: kind
|
||||
wait: 5s
|
||||
config: .github/kind/config.yaml # disable KIND-net
|
||||
# The versions below should target the oldest supported Kubernetes version
|
||||
# Keep this up-to-date with https://endoflife.date/kubernetes
|
||||
node_image: ghcr.io/fluxcd/kindest/node:v1.32.1-amd64
|
||||
kubectl_version: v1.32.0
|
||||
node_image: ghcr.io/fluxcd/kindest/node:v1.34.1-amd64
|
||||
kubectl_version: v1.34.0
|
||||
- name: Setup Calico for network policy
|
||||
run: |
|
||||
kubectl apply -f https://raw.githubusercontent.com/projectcalico/calico/v3.27.3/manifests/calico.yaml
|
||||
kubectl apply -f https://raw.githubusercontent.com/projectcalico/calico/v3.32.0/manifests/calico.yaml
|
||||
- name: Setup Kustomize
|
||||
uses: fluxcd/pkg/actions/kustomize@bf02f0a2d612cc07e0892166369fa8f63246aabb # main
|
||||
uses: fluxcd/pkg/actions/kustomize@5a7f3ce0de742b6c561a50f90940d81cf6fc698d # main
|
||||
- name: Run tests
|
||||
run: make test
|
||||
- name: Run e2e tests
|
||||
@@ -65,7 +65,7 @@ jobs:
|
||||
./bin/flux check --pre
|
||||
- name: flux install --manifests
|
||||
run: |
|
||||
./bin/flux install --manifests ./manifests/install/
|
||||
./bin/flux install --manifests ./manifests/test/
|
||||
- name: flux create secret
|
||||
run: |
|
||||
./bin/flux create secret git git-ssh-test \
|
||||
|
||||
@@ -19,21 +19,21 @@ jobs:
|
||||
actions: read
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- name: Run analysis
|
||||
uses: ossf/scorecard-action@05b42c624433fc40578a4040d5cf5e36ddca8cde # v2.4.2
|
||||
uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3
|
||||
with:
|
||||
results_file: results.sarif
|
||||
results_format: sarif
|
||||
repo_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
publish_results: true
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: SARIF file
|
||||
path: results.sarif
|
||||
retention-days: 5
|
||||
- name: Upload SARIF results
|
||||
uses: github/codeql-action/upload-sarif@192325c86100d080feab897ff886c34abd4c83a3 # v3.30.3
|
||||
uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
with:
|
||||
sarif_file: results.sarif
|
||||
|
||||
@@ -13,40 +13,44 @@ jobs:
|
||||
hashes: ${{ steps.slsa.outputs.hashes }}
|
||||
image_url: ${{ steps.slsa.outputs.image_url }}
|
||||
image_digest: ${{ steps.slsa.outputs.image_digest }}
|
||||
runs-on: ubuntu-latest
|
||||
runs-on:
|
||||
group: "Default Larger Runners"
|
||||
labels: ubuntu-latest-16-cores
|
||||
permissions:
|
||||
contents: write # needed to write releases
|
||||
id-token: write # needed for keyless signing
|
||||
packages: write # needed for ghcr access
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- name: Unshallow
|
||||
run: git fetch --prune --unshallow
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
with:
|
||||
go-version: 1.25.x
|
||||
go-version: 1.26.x
|
||||
cache: false
|
||||
- name: Setup QEMU
|
||||
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0
|
||||
uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0
|
||||
- name: Setup Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
- name: Setup Syft
|
||||
uses: anchore/sbom-action/download-syft@f8bdd1d8ac5e901a77a92f111440fdb1b593736b # v0.20.6
|
||||
uses: anchore/sbom-action/download-syft@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0
|
||||
- name: Setup Cosign
|
||||
uses: sigstore/cosign-installer@d7543c93d881b35a8faa02e8e3605f69b7a1ce62 # v3.10.0
|
||||
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
|
||||
with:
|
||||
cosign-release: v2.6.1 # TODO: remove after Flux 2.8 with support for cosign v3
|
||||
- name: Setup Kustomize
|
||||
uses: fluxcd/pkg/actions/kustomize@bf02f0a2d612cc07e0892166369fa8f63246aabb # main
|
||||
uses: fluxcd/pkg/actions/kustomize@5a7f3ce0de742b6c561a50f90940d81cf6fc698d # main
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: fluxcdbot
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
with:
|
||||
username: fluxcdbot
|
||||
password: ${{ secrets.DOCKER_FLUXCD_PASSWORD }}
|
||||
@@ -59,7 +63,7 @@ jobs:
|
||||
run: |
|
||||
kustomize build manifests/crds > all-crds.yaml
|
||||
- name: Generate OpenAPI JSON schemas from CRDs
|
||||
uses: fluxcd/pkg/actions/crdjsonschema@bf02f0a2d612cc07e0892166369fa8f63246aabb # main
|
||||
uses: fluxcd/pkg/actions/crdjsonschema@5a7f3ce0de742b6c561a50f90940d81cf6fc698d # main
|
||||
with:
|
||||
crd: all-crds.yaml
|
||||
output: schemas
|
||||
@@ -68,7 +72,7 @@ jobs:
|
||||
tar -czvf ./output/crd-schemas.tar.gz -C schemas .
|
||||
- name: Run GoReleaser
|
||||
id: run-goreleaser
|
||||
uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # v6.4.0
|
||||
uses: goreleaser/goreleaser-action@5daf1e915a5f0af01ddbcd89a43b8061ff4f1a89 # v7.2.2
|
||||
with:
|
||||
version: latest
|
||||
args: release --skip=validate
|
||||
@@ -99,24 +103,26 @@ jobs:
|
||||
id-token: write
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- name: Setup Kustomize
|
||||
uses: fluxcd/pkg/actions/kustomize@bf02f0a2d612cc07e0892166369fa8f63246aabb # main
|
||||
uses: fluxcd/pkg/actions/kustomize@5a7f3ce0de742b6c561a50f90940d81cf6fc698d # main
|
||||
- name: Setup Flux CLI
|
||||
uses: ./action/
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Prepare
|
||||
id: prep
|
||||
run: |
|
||||
VERSION=$(flux version --client | awk '{ print $NF }')
|
||||
echo "version=${VERSION}" >> $GITHUB_OUTPUT
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: fluxcdbot
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
with:
|
||||
username: fluxcdbot
|
||||
password: ${{ secrets.DOCKER_FLUXCD_PASSWORD }}
|
||||
@@ -144,7 +150,9 @@ jobs:
|
||||
--path="./flux-system" \
|
||||
--source=${{ github.repositoryUrl }} \
|
||||
--revision="${{ github.ref_name }}@sha1:${{ github.sha }}"
|
||||
- uses: sigstore/cosign-installer@d7543c93d881b35a8faa02e8e3605f69b7a1ce62 # v3.10.0
|
||||
- uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
|
||||
with:
|
||||
cosign-release: v2.6.1 # TODO: remove after Flux 2.8 with support for cosign v3
|
||||
- name: Sign manifests
|
||||
env:
|
||||
COSIGN_EXPERIMENTAL: 1
|
||||
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
permissions:
|
||||
contents: read # for reading the repository code.
|
||||
security-events: write # for uploading the CodeQL analysis results.
|
||||
uses: fluxcd/gha-workflows/.github/workflows/code-scan.yaml@v0.4.0
|
||||
uses: fluxcd/gha-workflows/.github/workflows/code-scan.yaml@v0.11.0
|
||||
secrets:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
fossa-token: ${{ secrets.FOSSA_TOKEN }}
|
||||
|
||||
@@ -12,6 +12,6 @@ jobs:
|
||||
permissions:
|
||||
contents: read # for reading the labels file.
|
||||
issues: write # for creating and updating labels.
|
||||
uses: fluxcd/gha-workflows/.github/workflows/labels-sync.yaml@v0.4.0
|
||||
uses: fluxcd/gha-workflows/.github/workflows/labels-sync.yaml@v0.11.0
|
||||
secrets:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -16,11 +16,11 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
with:
|
||||
go-version: 1.25.x
|
||||
go-version: 1.26.x
|
||||
cache-dependency-path: |
|
||||
**/go.sum
|
||||
**/go.mod
|
||||
@@ -96,7 +96,7 @@ jobs:
|
||||
|
||||
- name: Create Pull Request
|
||||
id: cpr
|
||||
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8
|
||||
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
|
||||
with:
|
||||
token: ${{ secrets.BOT_GITHUB_TOKEN }}
|
||||
commit-message: |
|
||||
@@ -106,7 +106,7 @@ jobs:
|
||||
committer: GitHub <noreply@github.com>
|
||||
author: fluxcdbot <fluxcdbot@users.noreply.github.com>
|
||||
signoff: true
|
||||
branch: update-components
|
||||
branch: update-components-${{ github.ref_name }}
|
||||
title: Update toolkit components
|
||||
body: |
|
||||
${{ steps.update.outputs.pr_body }}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
name: upgrade-fluxcd-pkg
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
upgrade-fluxcd-pkg:
|
||||
uses: fluxcd/gha-workflows/.github/workflows/upgrade-fluxcd-pkg.yaml@v0.11.0
|
||||
secrets:
|
||||
github-token: ${{ secrets.BOT_GITHUB_TOKEN }}
|
||||
@@ -88,22 +88,6 @@ brews:
|
||||
generate_completions_from_executable(bin/"flux", "completion")
|
||||
test: |
|
||||
system "#{bin}/flux --version"
|
||||
publishers:
|
||||
- name: aur-pkg-bin
|
||||
env:
|
||||
- AUR_BOT_SSH_PRIVATE_KEY={{ .Env.AUR_BOT_SSH_PRIVATE_KEY }}
|
||||
cmd: |
|
||||
.github/aur/flux-bin/publish.sh {{ .Version }}
|
||||
- name: aur-pkg-scm
|
||||
env:
|
||||
- AUR_BOT_SSH_PRIVATE_KEY={{ .Env.AUR_BOT_SSH_PRIVATE_KEY }}
|
||||
cmd: |
|
||||
.github/aur/flux-scm/publish.sh {{ .Version }}
|
||||
- name: aur-pkg-go
|
||||
env:
|
||||
- AUR_BOT_SSH_PRIVATE_KEY={{ .Env.AUR_BOT_SSH_PRIVATE_KEY }}
|
||||
cmd: |
|
||||
.github/aur/flux-go/publish.sh {{ .Version }}
|
||||
dockers:
|
||||
- image_templates:
|
||||
- 'fluxcd/flux-cli:{{ .Tag }}-amd64'
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
# AGENTS.md
|
||||
|
||||
Guidance for AI coding assistants working in `fluxcd/flux2`. Read this file before making changes.
|
||||
|
||||
## Contribution workflow for AI agents
|
||||
|
||||
These rules come from [`fluxcd/flux2/CONTRIBUTING.md`](https://github.com/fluxcd/flux2/blob/main/CONTRIBUTING.md) and apply to every Flux repository.
|
||||
|
||||
- **Do not add `Signed-off-by` or `Co-authored-by` trailers with your agent name.** Only a human can legally certify the DCO.
|
||||
- **Disclose AI assistance** with an `Assisted-by` trailer naming your agent and model:
|
||||
```sh
|
||||
git commit -s -m "Add support for X" --trailer "Assisted-by: <agent-name>/<model-id>"
|
||||
```
|
||||
The `-s` flag adds the human's `Signed-off-by` from their git config — do not remove it.
|
||||
- **Commit message format:** Subject in imperative mood ("Add feature X" instead of "Adding feature X"), capitalized, no trailing period, ≤50 characters. Body wrapped at 72 columns, explaining what and why. No `@mentions` or `#123` issue references in the commit — put those in the PR description.
|
||||
- **Trim verbiage:** in PR descriptions, commit messages, and code comments. No marketing prose, no restating the diff, no emojis.
|
||||
- **Rebase, don't merge:** Never merge `main` into the feature branch; rebase onto the latest `main` and push with `--force-with-lease`. Squash before merge when asked.
|
||||
- **Pre-PR gate:** `make tidy fmt vet && make test` must pass and the working tree must be clean.
|
||||
- **Flux is GA:** Backward compatibility is mandatory. Breaking changes to CLI flags, output format, or behavior will be rejected. Design additive changes.
|
||||
- **Copyright:** All new `.go` files must begin with the header from `cmd/flux/main.go` (Apache 2.0). Update the year to the current year when copying.
|
||||
- **Tests:** New features, improvements and fixes must have test coverage. Add unit tests in `cmd/flux/*_test.go` tagged `//go:build unit`. Follow the existing `cmdTestCase` + golden file patterns. Run tests locally before pushing.
|
||||
|
||||
## Code quality
|
||||
|
||||
Before submitting code, review your changes for the following:
|
||||
|
||||
- **No secrets in logs or output.** Never surface auth tokens, passwords, deploy keys, or credential URLs in error messages, log lines, or CLI output. Bootstrap and source-secret commands handle sensitive material — take extra care.
|
||||
- **No unchecked I/O.** Close HTTP response bodies, file handles, and tar readers in `defer` statements. Check and propagate errors from I/O operations.
|
||||
- **No path traversal.** Validate and sanitize file paths extracted from archives or user input. Never `filepath.Join` with untrusted components without validation.
|
||||
- **No command injection.** Do not shell out via `os/exec` for git, helm, or kustomize operations. Use the Go libraries already in use (`fluxcd/pkg/git`, `fluxcd/pkg/kustomize`, `fluxcd/pkg/ssa`).
|
||||
- **No hardcoded defaults for security settings.** TLS verification must remain enabled by default. Git auth settings come from user-provided secrets.
|
||||
- **Error handling.** Wrap errors with `%w` for chain inspection. Do not swallow errors silently. CLI errors must be actionable — tell the user what went wrong and how to fix it without leaking internal state.
|
||||
- **Resource cleanup.** Ensure temporary files and directories (manifest staging, downloaded tarballs) are cleaned up on all code paths (success and error). Use `defer` and `t.TempDir()` in tests.
|
||||
- **No panics.** Never use `panic` in runtime code paths. Return errors and let the CLI handle them gracefully.
|
||||
- **Output discipline.** Machine-readable data (tables, YAML, JSON) goes to stdout via `rootCmd.OutOrStdout()`. Human-readable status messages go to stderr via the `stderrLogger`.
|
||||
- **Minimal surface.** Keep new exported APIs in `pkg/` to the minimum needed. Every export is a backward-compatibility commitment.
|
||||
|
||||
## Project overview
|
||||
|
||||
flux2 is the Flux CLI (`flux` command) and distribution repository. It is **not** a controller — it consumes CRD APIs from six independent controller repos (source-controller, kustomize-controller, helm-controller, notification-controller, image-reflector-controller, image-automation-controller). It serves two purposes:
|
||||
|
||||
1. **CLI tool** — a Cobra-based binary that installs Flux onto Kubernetes clusters, bootstraps GitOps pipelines, and manages all Flux CRD objects (create, get, export, reconcile, suspend, resume, delete, diff, build, etc.).
|
||||
2. **Distribution hub** — it bundles the Kustomize manifests for all Flux controllers and releases them as `manifests.tar.gz` on GitHub. Those manifests are also compiled into the binary itself via `//go:embed`.
|
||||
|
||||
## Repository layout
|
||||
|
||||
- `cmd/flux/` — all CLI source. Single `main` package with one file per command or resource type. `main.go` defines the root cobra command with global flags. `manifests.embed.go` embeds the generated controller manifests via `//go:embed`.
|
||||
- `internal/build/` — `flux build kustomization` logic (kustomize-based diff/build, SOPS secret masking).
|
||||
- `internal/flags/` — custom `pflag.Value` types providing enum validation (e.g. `LogLevel`, `ECDSACurve`, `RSAKeyBits`, `PublicKeyAlgorithm`, `DecryptionProvider`).
|
||||
- `internal/tree/` — tree-printing helper for `flux tree kustomization`.
|
||||
- `internal/utils/` — shared helpers: `KubeClient`, `KubeConfig`, `NewScheme` (registers all controller API groups), `Apply` (SSA-based two-phase apply), `ExecKubectlCommand`, `ValidateComponents`.
|
||||
- `pkg/bootstrap/` — bootstrap orchestration: `Run()`, `PlainGitBootstrapper`, `ProviderBootstrapper`. `provider/` has the git provider factory (GitHub, GitLab, Gitea, Bitbucket).
|
||||
- `pkg/log/` — `Logger` interface (`Actionf`, `Generatef`, `Waitingf`, `Successf`, `Warningf`, `Failuref`).
|
||||
- `pkg/manifestgen/` — manifest generation for install, sync, kustomization, and source secrets.
|
||||
- `pkg/printers/` — specialized printers `TablePrinter` and `DyffPrinter`.
|
||||
- `pkg/status/` — `StatusChecker` using `fluxcd/cli-utils` kstatus polling.
|
||||
- `pkg/uninstall/` — `flux uninstall` logic.
|
||||
- `manifests/` — Kustomize bases per controller, RBAC, network policies, CRD references, and `scripts/bundle.sh` which runs `kustomize build` to generate `cmd/flux/manifests/`.
|
||||
- `tests/integration/` — cloud e2e tests (Azure/GCP) with their own `go.mod` and Terraform infrastructure.
|
||||
- `rfcs/` — Request for Comments documents for major design proposals and changes.
|
||||
|
||||
## CLI architecture
|
||||
|
||||
Commands are Cobra-based, organized as parent + per-resource children:
|
||||
- Group files (`create.go`, `get.go`, `reconcile.go`, etc.) register the parent subcommand.
|
||||
- Per-resource files (`create_kustomization.go`, `get_helmrelease.go`, etc.) register children.
|
||||
|
||||
Core interfaces in `cmd/flux/` enable generic command implementations:
|
||||
- `adapter` / `copyable` / `listAdapter` — wrap controller API types for generic CRUD.
|
||||
- `reconcilable` — annotate-and-poll pattern for triggering reconciliation.
|
||||
- `summarisable` — generic table output for `get` commands.
|
||||
|
||||
Each resource type (e.g. `kustomizationAdapter` in `kustomization.go`) wraps the controller API type and implements these interfaces. Follow this pattern when adding new resource support.
|
||||
|
||||
Commands interact with the Kubernetes API via `internal/utils.KubeClient()` → `client.WithWatch`. `internal/utils.NewScheme()` registers all six controller API groups plus core k8s types. `internal/utils.Apply()` implements SSA-based two-phase apply (CRDs/Namespaces first, then remaining objects).
|
||||
|
||||
## Manifest pipeline
|
||||
|
||||
1. `manifests/bases/<controller>/` contains a Kustomize base per controller referencing the controller's GitHub release for CRDs and deployment manifests.
|
||||
2. `manifests/install/kustomization.yaml` assembles all bases plus RBAC and policies.
|
||||
3. `manifests/scripts/bundle.sh` runs `kustomize build` on each base, writing output to `cmd/flux/manifests/` (not checked in — generated).
|
||||
4. The Makefile `$(EMBEDDED_MANIFESTS_TARGET)` runs `bundle.sh` and creates a sentinel file `cmd/flux/.manifests.done`.
|
||||
5. `cmd/flux/manifests.embed.go` uses `//go:embed manifests/*.yaml` to compile everything into the binary.
|
||||
|
||||
When modifying `manifests/`, always run `make build` and verify the generated output before committing. Never hand-edit files under `cmd/flux/manifests/`.
|
||||
|
||||
## Build, test, lint
|
||||
|
||||
All targets in the root `Makefile`. Go version tracks `go.mod`.
|
||||
|
||||
- `make tidy` — tidy the root module and `tests/integration/`.
|
||||
- `make fmt` / `make vet` — run in the root module.
|
||||
- `make build` — builds `bin/flux` (CGO disabled, version injected via ldflags). Depends on embedded manifests being generated.
|
||||
- `make build-dev` — builds with `DEV_VERSION`.
|
||||
- `make install` / `make install-dev` — `go install` or copy to `/usr/local/bin`.
|
||||
- `make test` — unit tests with envtest: runs `tidy fmt vet install-envtest`, then `go test ./... -coverprofile cover.out --tags=unit $(TEST_ARGS)`.
|
||||
- `make e2e` — e2e tests against a live cluster: `go test ./cmd/flux/... --tags=e2e -v -failfast`.
|
||||
- `make test-with-kind` — sets up a kind cluster, runs e2e, tears it down.
|
||||
- `make install-envtest` — downloads `setup-envtest` and fetches k8s binaries into `testbin/`.
|
||||
|
||||
Run a single test: `make test TEST_ARGS='-run TestCreate -v'`.
|
||||
|
||||
## Codegen and generated files
|
||||
|
||||
Check `go.mod` and the `Makefile` for current dependency and tool versions. The main codegen pipeline is the manifest bundle:
|
||||
|
||||
```sh
|
||||
./manifests/scripts/bundle.sh
|
||||
```
|
||||
|
||||
Generated files (never hand-edit):
|
||||
|
||||
- `cmd/flux/manifests/*.yaml` — generated by `bundle.sh` from `manifests/` sources.
|
||||
- `cmd/flux/.manifests.done` — sentinel file tracking bundle state.
|
||||
|
||||
Bump `fluxcd/pkg/*` and controller `api` modules as a set. Run `make tidy` after any bump.
|
||||
|
||||
## Conventions
|
||||
|
||||
- Standard `gofmt`. All exported names need doc comments.
|
||||
- **Command pattern:** follow the existing group-parent + per-resource-child cobra structure. New resources need an adapter type implementing `adapter`, `copyable`, and the relevant command interfaces (`reconcilable`, `summarisable`, etc.).
|
||||
- **Output:** stderr for human status messages via `stderrLogger` (Unicode symbols: `►` action, `✔` success, `✗` failure, `◎` waiting, `⚠️` warning, `✚` generate). Stdout for machine-readable data (tables, YAML, JSON) via `rootCmd.OutOrStdout()`.
|
||||
- **Global flags:** kubeconfig flags come from `k8s.io/cli-runtime/pkg/genericclioptions.ConfigFlags`. Client tuning comes from `fluxcd/pkg/runtime/client.Options`. `FLUX_SYSTEM_NAMESPACE` env var overrides the default namespace.
|
||||
- **SSA apply:** always use `internal/utils.Apply()` (two-phase: CRDs/Namespaces first, then rest). Do not apply manifests directly via the k8s client.
|
||||
- **Reconcile triggering:** patch `meta.ReconcileRequestAnnotation` with a timestamp, then poll with `kstatus.Compute()` until ready. See `reconcile.go`.
|
||||
- **Error handling:** return errors from `RunE`. Use `*RequestError` with exit codes for actionable CLI errors. Exit code 1 = warning, anything else = failure.
|
||||
- **Flags:** use `internal/flags/` custom `pflag.Value` types for enum flags (providers, algorithms, sources). Add new enum types there.
|
||||
|
||||
## Testing
|
||||
|
||||
Three test suites with build tags:
|
||||
|
||||
- **Unit** (`//go:build unit`): lives in `cmd/flux/*_test.go`. Uses `controller-runtime/envtest` for an in-process fake k8s API. CRDs are loaded from `cmd/flux/manifests/` (embedded manifests). Pattern: `cmdTestCase{args: "...", assert: assertGoldenFile("testdata/...")}`. The `executeCommand()` helper captures stdout.
|
||||
- **E2e** (`//go:build e2e`): lives in `cmd/flux/*_test.go`. Requires a live cluster via `TEST_KUBECONFIG`. `TestMain` runs `flux install` for setup and teardown.
|
||||
- **Integration** (`//go:build integration`): lives in `tests/integration/` with its own `go.mod`. Uses Terraform-provisioned cloud clusters.
|
||||
|
||||
Golden files live in `cmd/flux/testdata/`. Update them with `go test ./cmd/flux/... --tags=unit -update`.
|
||||
|
||||
Run a single unit test: `make test TEST_ARGS='-run TestInstall -v'`.
|
||||
|
||||
## Gotchas and non-obvious rules
|
||||
|
||||
- The `cmd/flux/manifests/` directory is **generated, not checked in**. It is created by `manifests/scripts/bundle.sh` and embedded into the binary. `make build` and `make test` both trigger the bundle if the sentinel file is stale.
|
||||
- `kustomize` must be on `PATH` for `bundle.sh` to work. If you see "command not found" errors during build, install kustomize.
|
||||
- `internal/utils.NewScheme()` registers all six controller API groups. Adding support for a new CRD type means updating the scheme registration there.
|
||||
- The `VERSION` constant is injected via `-ldflags` at build time. In dev builds it defaults to `0.0.0-dev.0`. The embedded manifest version check (`isEmbeddedVersion`) determines whether `flux install` uses compiled-in manifests or downloads from GitHub.
|
||||
- `resetCmdArgs()` in tests is critical — Cobra persists flag state between test runs. Every test case must reset to avoid pollution.
|
||||
- `executeCommand()` captures stdout only. Stderr output (from `stderrLogger`) is not captured in test assertions. If your command's output goes to the wrong stream, tests will silently pass with empty golden files.
|
||||
- The `adapter` / `listAdapter` interfaces use type assertions internally. If you add a new resource type and forget to implement an interface method, you'll get a runtime panic in the generic command handler, not a compile error. Add interface compliance checks (`var _ reconcilable = ...`).
|
||||
- Bootstrap commands create real Git commits and push to real repos. E2e tests for bootstrap need careful cleanup. Do not add bootstrap e2e tests without a corresponding teardown.
|
||||
- `pkg/` packages are importable by external consumers (e.g. Terraform provider, other tools). Treat their exported surface as public API.
|
||||
+105
-130
@@ -1,154 +1,129 @@
|
||||
# Contributing
|
||||
|
||||
Flux is [Apache 2.0 licensed](https://github.com/fluxcd/flux2/blob/main/LICENSE) and
|
||||
accepts contributions via GitHub pull requests. This document outlines
|
||||
some of the conventions on to make it easier to get your contribution
|
||||
accepted.
|
||||
Flux is [Apache 2.0 licensed](https://github.com/fluxcd/flux2/blob/main/LICENSE) and accepts contributions via GitHub pull requests.
|
||||
This document outlines the conventions to get your contribution accepted.
|
||||
We gratefully welcome improvements to documentation as well as code contributions.
|
||||
|
||||
We gratefully welcome improvements to issues and documentation as well as to
|
||||
code.
|
||||
If you are new to the project, we recommend starting with documentation improvements or
|
||||
small bug fixes to get familiar with the codebase and the contribution process.
|
||||
|
||||
## Project Structure
|
||||
|
||||
The Flux project consists of a set of Kubernetes controllers and tools that implement the GitOps pattern.
|
||||
The main repositories in the Flux project are:
|
||||
|
||||
- [fluxcd/flux2](https://github.com/fluxcd/flux2): The Flux distribution and command-line interface (CLI)
|
||||
- [fluxcd/pkg](https://github.com/fluxcd/pkg): The GitOps Toolkit Go SDK for building Flux controllers and CLI plugins
|
||||
- [fluxcd/source-controller](https://github.com/fluxcd/source-controller): Kubernetes operator for managing sources (Git, OCI and Helm repositories, S3-compatible Buckets)
|
||||
- [fluxcd/source-watcher](https://github.com/fluxcd/source-watcher): Kubernetes operator for advanced source composition and decomposition patterns
|
||||
- [fluxcd/kustomize-controller](https://github.com/fluxcd/kustomize-controller): Kubernetes operator for building GitOps pipelines with Kustomize
|
||||
- [fluxcd/helm-controller](https://github.com/fluxcd/helm-controller): Kubernetes operator for lifecycle management of Helm releases
|
||||
- [fluxcd/notification-controller](https://github.com/fluxcd/notification-controller): Kubernetes operator for handling inbound and outbound events (alerts and webhook receivers)
|
||||
- [fluxcd/image-reflector-controller](https://github.com/fluxcd/image-reflector-controller): Kubernetes operator for scanning container registries for new image tags and digests
|
||||
- [fluxcd/image-automation-controller](https://github.com/fluxcd/image-automation-controller): Kubernetes operator for patching container image tags and digests in Git repositories
|
||||
- [fluxcd/website](https://github.com/fluxcd/website): The Flux documentation website accessible at <https://fluxcd.io/>
|
||||
|
||||
## AI Coding Assistants Guidance
|
||||
|
||||
Using AI Agents to help write your PR is acceptable, but as the author, you are responsible
|
||||
for understanding the code and the documentation you submit. Please review all the AI-generated
|
||||
content and make sure it follows the guidelines in this document before submitting your PR.
|
||||
|
||||
All Flux repositories contain an `AGENTS.md` file. You must point your AI Agent to
|
||||
`AGENTS.md` and ask it to follow the guidelines and conventions described there.
|
||||
|
||||
Trim down the verbiage in the PR description, commit messages and code comments.
|
||||
When engaging with Flux maintainers please refrain from using AI Agents to
|
||||
generate responses, we want to talk to you, not to your AI Agent.
|
||||
|
||||
AI Agents **must not** add `Signed-off-by` or `Co-authored-by` tags to the commit message.
|
||||
Only humans can legally certify the Developer Certificate of Origin ([DCO](https://developercertificate.org/)).
|
||||
|
||||
You should disclose the use of AI Agents in the description of your PR and
|
||||
in the commit message using the `Assisted-by: AGENT_NAME/LLM_VERSION` tag.
|
||||
|
||||
Adding the `Assisted-by` tag to the commit message can be done with:
|
||||
|
||||
```sh
|
||||
git commit -s -m "Your commit message" --trailer "Assisted-by: <agent>/<model>"
|
||||
```
|
||||
|
||||
**Note** that the `Signed-off-by` tag is set via the `-s` flag using your real name and email
|
||||
(`user.name` and `user.email` must be set in Git config).
|
||||
|
||||
Example of a commit message disclosing the use of AI assistance:
|
||||
|
||||
```text
|
||||
Add version info to plugin listing
|
||||
|
||||
Add a version column to the `flux plugin list` table output and populate
|
||||
it with the semantic version info extracted from the plugin's recipe file.
|
||||
For plugins installed via symlinks, the version is set to `unknown`.
|
||||
|
||||
Signed-off-by: Jane Doe <jane.doe@example.com>
|
||||
Assisted-by: copilot/gpt-5.4
|
||||
```
|
||||
|
||||
## Certificate of Origin
|
||||
|
||||
By contributing to this project you agree to the Developer Certificate of
|
||||
Origin (DCO). This document was created by the Linux Kernel community and is a
|
||||
simple statement that you, as a contributor, have the legal right to make the
|
||||
contribution.
|
||||
By contributing to this project you agree to the Developer Certificate of Origin (DCO).
|
||||
This document was created by the Linux Kernel community and is a simple statement that you,
|
||||
as a contributor, have the legal right to make the contribution.
|
||||
|
||||
We require all commits to be signed. By signing off with your signature, you
|
||||
certify that you wrote the patch or otherwise have the right to contribute the
|
||||
material by the rules of the [DCO](DCO):
|
||||
We require all commits to be signed. By signing off with your signature, you certify that you wrote
|
||||
the patch or otherwise have the right to contribute the material by the rules of the [DCO](https://raw.githubusercontent.com/fluxcd/flux2/refs/heads/main/DCO):
|
||||
|
||||
`Signed-off-by: Jane Doe <jane.doe@example.com>`
|
||||
|
||||
The signature must contain your real name
|
||||
(sorry, no pseudonyms or anonymous contributions)
|
||||
If your `user.name` and `user.email` are configured in your Git config,
|
||||
The signature must contain your real name (sorry, no pseudonyms or anonymous contributions).
|
||||
If your `user.name` and `user.email` are set in your Git config,
|
||||
you can sign your commit automatically with `git commit -s`.
|
||||
|
||||
## Communications
|
||||
|
||||
For realtime communications we use Slack: To join the conversation, simply
|
||||
join the [CNCF](https://slack.cncf.io/) Slack workspace and use the
|
||||
[#flux-contributors](https://cloud-native.slack.com/messages/flux-contributors/) channel.
|
||||
|
||||
To discuss ideas and specifications we use [Github
|
||||
Discussions](https://github.com/fluxcd/flux2/discussions).
|
||||
|
||||
For announcements we use a mailing list as well. Simply subscribe to
|
||||
[flux-dev on cncf.io](https://lists.cncf.io/g/cncf-flux-dev)
|
||||
to join the conversation (there you can also add calendar invites
|
||||
to your Google calendar for our [Flux
|
||||
meeting](https://docs.google.com/document/d/1l_M0om0qUEN_NNiGgpqJ2tvsF2iioHkaARDeh6b70B0/view)).
|
||||
|
||||
## Understanding Flux and the GitOps Toolkit
|
||||
|
||||
If you are entirely new to Flux and the GitOps Toolkit,
|
||||
you might want to take a look at the [introductory talk and demo](https://www.youtube.com/watch?v=qQBtSkgl7tI).
|
||||
|
||||
This project is composed of:
|
||||
|
||||
- [flux2](https://github.com/fluxcd/flux2): The Flux CLI
|
||||
- [source-manager](https://github.com/fluxcd/source-controller): Kubernetes operator for managing sources (Git and Helm repositories, S3-compatible Buckets)
|
||||
- [kustomize-controller](https://github.com/fluxcd/kustomize-controller): Kubernetes operator for building GitOps pipelines with Kustomize
|
||||
- [helm-controller](https://github.com/fluxcd/helm-controller): Kubernetes operator for building GitOps pipelines with Helm
|
||||
- [notification-controller](https://github.com/fluxcd/notification-controller): Kubernetes operator for handling inbound and outbound events
|
||||
- [image-reflector-controller](https://github.com/fluxcd/image-reflector-controller): Kubernetes operator for scanning container registries
|
||||
- [image-automation-controller](https://github.com/fluxcd/image-automation-controller): Kubernetes operator for patches container image tags in Git
|
||||
|
||||
### Understanding the code
|
||||
|
||||
To get started with developing controllers, you might want to review
|
||||
[our guide](https://fluxcd.io/flux/gitops-toolkit/source-watcher/) which
|
||||
walks you through writing a short and concise controller that watches out
|
||||
for source changes.
|
||||
|
||||
## How to run the test suite
|
||||
|
||||
Prerequisites:
|
||||
|
||||
* go >= 1.24
|
||||
* kubectl >= 1.30
|
||||
* kustomize >= 5.0
|
||||
* coreutils (on Mac OS)
|
||||
|
||||
Install the [controller-runtime/envtest](https://github.com/kubernetes-sigs/controller-runtime/tree/master/tools/setup-envtest) binaries with:
|
||||
|
||||
```bash
|
||||
make install-envtest
|
||||
```
|
||||
|
||||
Then you can run the unit tests with:
|
||||
|
||||
```bash
|
||||
make test
|
||||
```
|
||||
|
||||
After [installing Kubernetes kind](https://kind.sigs.k8s.io/docs/user/quick-start#installation) on your machine,
|
||||
create a cluster for testing with:
|
||||
|
||||
```bash
|
||||
make setup-kind
|
||||
```
|
||||
|
||||
Then you can run the end-to-end tests with:
|
||||
|
||||
```bash
|
||||
make e2e
|
||||
```
|
||||
|
||||
When the output of the Flux CLI changes, to automatically update the golden
|
||||
files used in the test, pass `-update` flag to the test as:
|
||||
|
||||
```bash
|
||||
make e2e TEST_ARGS="-update"
|
||||
```
|
||||
|
||||
Since not all packages use golden files for testing, `-update` argument must be
|
||||
passed only for the packages that use golden files. Use the variables
|
||||
`TEST_PKG_PATH` for unit tests and `E2E_TEST_PKG_PATH` for e2e tests, to set the
|
||||
path of the target test package:
|
||||
|
||||
```bash
|
||||
# Unit test
|
||||
make test TEST_PKG_PATH="./cmd/flux" TEST_ARGS="-update"
|
||||
# e2e test
|
||||
make e2e E2E_TEST_PKG_PATH="./cmd/flux" TEST_ARGS="-update"
|
||||
```
|
||||
|
||||
Teardown the e2e environment with:
|
||||
|
||||
```bash
|
||||
make cleanup-kind
|
||||
```
|
||||
|
||||
## Acceptance policy
|
||||
|
||||
These things will make a PR more likely to be accepted:
|
||||
|
||||
- a well-described requirement
|
||||
- tests for new code
|
||||
- tests for old code!
|
||||
- new code and tests follow the conventions in old code and tests
|
||||
- a good commit message (see below)
|
||||
- all code must abide [Go Code Review Comments](https://github.com/golang/go/wiki/CodeReviewComments)
|
||||
- names should abide [What's in a name](https://talks.golang.org/2014/names.slide#1)
|
||||
- code must build on both Linux and Darwin, via plain `go build`
|
||||
- code should have appropriate test coverage and tests should be written
|
||||
to work with `go test`
|
||||
- Addressing an open issue, if one doesn't exist, please open an issue to discuss the problem and the proposed solution before submitting a PR.
|
||||
- Flux is GA software and we are committed to maintaining backward compatibility. If your contribution introduces a breaking change, expect for your PR to be rejected.
|
||||
- New code and tests must follow the conventions in the existing code and tests. All new code must have good test coverage and be well documented.
|
||||
- All top-level Go code and exported names should have doc comments, as should non-trivial unexported type or function declarations.
|
||||
- Before submitting a PR, make sure that your code is properly formatted by running `make tidy fmt vet` and that all tests are passing by running `make test`.
|
||||
|
||||
In general, we will merge a PR once one maintainer has endorsed it.
|
||||
For substantial changes, more people may become involved, and you might
|
||||
get asked to resubmit the PR or divide the changes into more than one PR.
|
||||
|
||||
### Format of the Commit Message
|
||||
## Format of the Commit Message
|
||||
|
||||
For the GitOps Toolkit controllers we prefer the following rules for good commit messages:
|
||||
For the Flux project we prefer the following rules:
|
||||
|
||||
- Limit the subject to 50 characters and write as the continuation
|
||||
of the sentence "If applied, this commit will ..."
|
||||
- Explain what and why in the body, if more than a trivial change;
|
||||
wrap it at 72 characters.
|
||||
- Limit the subject to 50 characters, start with a capital letter and do not end with a period.
|
||||
- Explain what and why in the body, if more than a trivial change; wrap it at 72 characters.
|
||||
- Use the imperative mood in the subject line (e.g., "Add support for X" instead of "Added support for X" or "Adds support for X").
|
||||
- Do not include GitHub mentions to issues in the commit message, use the PR description instead (e.g., "Fixes #123" or "Closes #123").
|
||||
- Do not include GitHub mentions to accounts (e.g., `@username` or `@team`) within the commit message.
|
||||
|
||||
The [following article](https://chris.beams.io/posts/git-commit/#seven-rules)
|
||||
has some more helpful advice on documenting your work.
|
||||
## Pull Request Process
|
||||
|
||||
Fork the repository and create a new branch for your changes, do not commit directly to the `main` branch.
|
||||
Once you have made your changes and committed them, push your branch to your fork and open a pull request
|
||||
against the `main` branch of the Flux repository.
|
||||
|
||||
During the review process, you may be asked to make changes to your PR. Add commits to address the feedback
|
||||
without force pushing, as this will make it easier for reviewers to see the changes.
|
||||
Before committing, make sure to run `make test` to ensure that your code will pass the CI checks.
|
||||
|
||||
When the review process is complete, you will be asked to **squash** the commits and **rebase** your branch.
|
||||
**Do not merge** the `main` branch into your branch, instead, rebase your branch on top of the latest `main`
|
||||
branch after **syncing your fork** with the latest changes from the Flux repository. After rebasing,
|
||||
you can push your branch with the `--force-with-lease` option to update the PR.
|
||||
|
||||
## Communications
|
||||
|
||||
For realtime communications we use Slack. To reach out to the Flux maintainers and contributors,
|
||||
join the [CNCF](https://slack.cncf.io/) Slack workspace and use the [#flux-contributors](https://cloud-native.slack.com/messages/flux-contributors/) channel.
|
||||
To discuss ideas and specifications we use [GitHub Discussions](https://github.com/fluxcd/flux2/discussions).
|
||||
|
||||
For announcements, we use a mailing list as well. Subscribe to
|
||||
[flux-dev on cncf.io](https://lists.cncf.io/g/cncf-flux-dev), there you can also add calendar invites
|
||||
to your Google calendar for our [Flux dev meeting](https://docs.google.com/document/d/1l_M0om0qUEN_NNiGgpqJ2tvsF2iioHkaARDeh6b70B0/view).
|
||||
|
||||
+3
-3
@@ -1,16 +1,16 @@
|
||||
FROM alpine:3.22 AS builder
|
||||
FROM alpine:3.23 AS builder
|
||||
|
||||
RUN apk add --no-cache ca-certificates curl
|
||||
|
||||
ARG ARCH=linux/amd64
|
||||
ARG KUBECTL_VER=1.34.0
|
||||
ARG KUBECTL_VER=1.36.1
|
||||
|
||||
RUN curl -sL https://dl.k8s.io/release/v${KUBECTL_VER}/bin/${ARCH}/kubectl \
|
||||
-o /usr/local/bin/kubectl && chmod +x /usr/local/bin/kubectl
|
||||
|
||||
RUN kubectl version --client=true
|
||||
|
||||
FROM alpine:3.22 AS flux-cli
|
||||
FROM alpine:3.23 AS flux-cli
|
||||
|
||||
RUN apk add --no-cache ca-certificates
|
||||
|
||||
|
||||
@@ -2,8 +2,9 @@ VERSION?=$(shell grep 'VERSION' cmd/flux/main.go | awk '{ print $$4 }' | head -n
|
||||
DEV_VERSION?=0.0.0-$(shell git rev-parse --abbrev-ref HEAD)-$(shell git rev-parse --short HEAD)-$(shell date +%s)
|
||||
EMBEDDED_MANIFESTS_TARGET=cmd/flux/.manifests.done
|
||||
TEST_KUBECONFIG?=/tmp/flux-e2e-test-kubeconfig
|
||||
# Architecture to use envtest with
|
||||
ENVTEST_ARCH ?= amd64
|
||||
# Architecture to use envtest with; defaults to the host architecture.
|
||||
LOCALARCH ?= $(shell go env GOARCH)
|
||||
ENVTEST_ARCH ?= $(LOCALARCH)
|
||||
|
||||
# Get the currently used golang install path (in GOPATH/bin, unless GOBIN is set)
|
||||
ifeq (,$(shell go env GOBIN))
|
||||
@@ -17,8 +18,8 @@ rwildcard=$(foreach d,$(wildcard $(addsuffix *,$(1))),$(call rwildcard,$(d)/,$(2
|
||||
all: test build
|
||||
|
||||
tidy:
|
||||
go mod tidy -compat=1.25
|
||||
cd tests/integration && go mod tidy -compat=1.25
|
||||
go mod tidy -compat=1.26
|
||||
cd tests/integration && go mod tidy -compat=1.26
|
||||
|
||||
fmt:
|
||||
go fmt ./...
|
||||
|
||||
@@ -52,12 +52,14 @@ guides](https://fluxcd.io/flux/gitops-toolkit/source-watcher/).
|
||||
|
||||
### Components
|
||||
|
||||
- [Source Controller](https://fluxcd.io/flux/components/source/)
|
||||
- [Source Controllers](https://fluxcd.io/flux/components/source/)
|
||||
- [GitRepository CRD](https://fluxcd.io/flux/components/source/gitrepositories/)
|
||||
- [OCIRepository CRD](https://fluxcd.io/flux/components/source/ocirepositories/)
|
||||
- [HelmRepository CRD](https://fluxcd.io/flux/components/source/helmrepositories/)
|
||||
- [HelmChart CRD](https://fluxcd.io/flux/components/source/helmcharts/)
|
||||
- [Bucket CRD](https://fluxcd.io/flux/components/source/buckets/)
|
||||
- [ExternalArtifact CRD](https://fluxcd.io/flux/components/source/externalartifacts/)
|
||||
- [ArtifactGenerator CRD](https://fluxcd.io/flux/components/source/artifactgenerators/)
|
||||
- [Kustomize Controller](https://fluxcd.io/flux/components/kustomize/)
|
||||
- [Kustomization CRD](https://fluxcd.io/flux/components/kustomize/kustomizations/)
|
||||
- [Helm Controller](https://fluxcd.io/flux/components/helm/)
|
||||
|
||||
@@ -12,6 +12,26 @@ steps:
|
||||
run: flux version --client
|
||||
```
|
||||
|
||||
To install Flux plugins alongside the CLI:
|
||||
|
||||
```yaml
|
||||
steps:
|
||||
- name: Setup Flux CLI
|
||||
uses: fluxcd/flux2/action@main
|
||||
with:
|
||||
plugins: |
|
||||
mirror@0.0.1
|
||||
- name: Run Flux plugin
|
||||
run: flux mirror --help
|
||||
```
|
||||
|
||||
Installing plugins requires a Flux version with plugin support (v2.9.0 or later).
|
||||
The `plugins` input accepts one plugin per line in `<name>`,
|
||||
`<name>@<version>`, or `<name>@<digest>` format. Entries without a version or
|
||||
digest install the latest version from the catalog. The `plugin-dir` input is
|
||||
only used when `plugins` is set; when plugins are installed, the action exports
|
||||
`FLUXCD_PLUGINS` for subsequent steps.
|
||||
|
||||
The Flux GitHub Action can be used to automate various tasks in CI, such as:
|
||||
|
||||
- [Automate Flux upgrades on clusters via Pull Requests](https://fluxcd.io/flux/flux-gh-action/#automate-flux-updates)
|
||||
|
||||
+113
-3
@@ -12,17 +12,36 @@ inputs:
|
||||
description: "arch can be amd64, arm64 or arm"
|
||||
required: false
|
||||
deprecationMessage: "No longer required, action will now detect runner arch."
|
||||
plugins:
|
||||
description: >
|
||||
Plugins to install alongside Flux. One per line: `<name>`, `<name>@<version>` or
|
||||
`<name>@<digest>`. Entries without a version or digest install the latest
|
||||
version from the catalog. Example: `mirror@0.0.1`
|
||||
required: false
|
||||
plugin-dir:
|
||||
description: >
|
||||
Directory where requested plugins are installed. This input is only used when
|
||||
`plugins` is set. When plugins are installed, the action exports
|
||||
`FLUXCD_PLUGINS` for subsequent steps. Defaults to a `plugins` directory
|
||||
alongside the Flux binary in $RUNNER_TOOL_CACHE.
|
||||
required: false
|
||||
bindir:
|
||||
description: "Alternative location for the Flux binary, defaults to path relative to $RUNNER_TOOL_CACHE."
|
||||
required: false
|
||||
token:
|
||||
description: "Token used to authenticate against the GitHub.com API."
|
||||
required: false
|
||||
outputs:
|
||||
plugin-dir:
|
||||
description: >
|
||||
Directory where the plugins were installed by the action.
|
||||
value: ${{ steps.set-outputs.outputs.plugin-dir }}
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: "Download the binary to the runner's cache dir"
|
||||
shell: bash
|
||||
id: setup-flux-bin
|
||||
env:
|
||||
VERSION: "${{ inputs.version }}"
|
||||
FLUX_TOOL_DIR: "${{ inputs.bindir }}"
|
||||
@@ -42,11 +61,13 @@ runs:
|
||||
if [[ $VERSION = v* ]]; then
|
||||
VERSION="${VERSION:1}"
|
||||
fi
|
||||
echo "installed-flux-version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
|
||||
OS=$(echo "${RUNNER_OS}" | tr '[:upper:]' '[:lower:]')
|
||||
if [[ "$OS" == "macos" ]]; then
|
||||
OS="darwin"
|
||||
fi
|
||||
echo "os=$OS" >> "$GITHUB_OUTPUT"
|
||||
|
||||
ARCH=$(echo "${RUNNER_ARCH}" | tr '[:upper:]' '[:lower:]')
|
||||
if [[ "$ARCH" == "x64" ]]; then
|
||||
@@ -54,6 +75,7 @@ runs:
|
||||
elif [[ "$ARCH" == "x86" ]]; then
|
||||
ARCH="386"
|
||||
fi
|
||||
echo "arch=$ARCH" >> "$GITHUB_OUTPUT"
|
||||
|
||||
FLUX_EXEC_FILE="flux"
|
||||
if [[ "$OS" == "windows" ]]; then
|
||||
@@ -63,7 +85,7 @@ runs:
|
||||
if [[ -z "$FLUX_TOOL_DIR" ]]; then
|
||||
FLUX_TOOL_DIR="${RUNNER_TOOL_CACHE}/flux2/${VERSION}/${OS}/${ARCH}"
|
||||
fi
|
||||
if [[ ! -x "$FLUX_TOOL_DIR/FLUX_EXEC_FILE" ]]; then
|
||||
if [[ ! -x "$FLUX_TOOL_DIR/$FLUX_EXEC_FILE" ]]; then
|
||||
DL_DIR="$(mktemp -dt flux2-XXXXXX)"
|
||||
trap 'rm -rf $DL_DIR' EXIT
|
||||
|
||||
@@ -77,8 +99,36 @@ runs:
|
||||
|
||||
FLUX_DOWNLOAD_URL="https://github.com/fluxcd/flux2/releases/download/v${VERSION}/"
|
||||
|
||||
curl -fsSL -o "$DL_DIR/$FLUX_TARGET_FILE" "$FLUX_DOWNLOAD_URL/$FLUX_TARGET_FILE"
|
||||
curl -fsSL -o "$DL_DIR/$FLUX_CHECKSUMS_FILE" "$FLUX_DOWNLOAD_URL/$FLUX_CHECKSUMS_FILE"
|
||||
MAX_RETRIES=5
|
||||
RETRY_DELAY=5
|
||||
|
||||
for i in $(seq 1 $MAX_RETRIES); do
|
||||
echo "Downloading flux binary (attempt $i/$MAX_RETRIES)"
|
||||
if curl -fsSL -o "$DL_DIR/$FLUX_TARGET_FILE" "$FLUX_DOWNLOAD_URL/$FLUX_TARGET_FILE"; then
|
||||
break
|
||||
fi
|
||||
if [ $i -lt $MAX_RETRIES ]; then
|
||||
echo "Download failed, retrying in ${RETRY_DELAY} seconds..."
|
||||
sleep $RETRY_DELAY
|
||||
else
|
||||
echo "Failed to download flux binary after $MAX_RETRIES attempts"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
for i in $(seq 1 $MAX_RETRIES); do
|
||||
echo "Downloading checksums file (attempt $i/$MAX_RETRIES)"
|
||||
if curl -fsSL -o "$DL_DIR/$FLUX_CHECKSUMS_FILE" "$FLUX_DOWNLOAD_URL/$FLUX_CHECKSUMS_FILE"; then
|
||||
break
|
||||
fi
|
||||
if [ $i -lt $MAX_RETRIES ]; then
|
||||
echo "Download failed, retrying in ${RETRY_DELAY} seconds..."
|
||||
sleep $RETRY_DELAY
|
||||
else
|
||||
echo "Failed to download checksums file after $MAX_RETRIES attempts"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
echo "Verifying checksum"
|
||||
sum=""
|
||||
@@ -118,3 +168,63 @@ runs:
|
||||
shell: bash
|
||||
run: |
|
||||
flux -v
|
||||
|
||||
- name: "Plugin support gate"
|
||||
id: plugin-support-gate
|
||||
shell: bash
|
||||
if: inputs.plugins != ''
|
||||
run: |
|
||||
flux_version="$(flux -v 2>/dev/null | awk '{print $3}')"
|
||||
if flux plugin --help >/dev/null 2>&1; then
|
||||
echo "plugin-support=yes" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
else
|
||||
echo "plugin-support=no" >> "$GITHUB_OUTPUT"
|
||||
msg="Installed Flux version ${flux_version:-unknown} does not support plugins; need >= 2.9.0. Requested plugins cannot be installed."
|
||||
echo "::error title=Unsupported Flux version::$msg"
|
||||
echo "> [!CAUTION]" >> $GITHUB_STEP_SUMMARY
|
||||
echo "> $msg" >> $GITHUB_STEP_SUMMARY
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: "Setup Plugins Dir"
|
||||
shell: bash
|
||||
if: steps.plugin-support-gate.outputs.plugin-support == 'yes'
|
||||
env:
|
||||
FLUXCD_PLUGINS: ${{ inputs.plugin-dir }}
|
||||
VERSION: ${{ steps.setup-flux-bin.outputs.installed-flux-version }}
|
||||
OS: ${{ steps.setup-flux-bin.outputs.os }}
|
||||
ARCH: ${{ steps.setup-flux-bin.outputs.arch }}
|
||||
run: |
|
||||
# Use a default plugin directory when plugin-dir was not provided.
|
||||
if [[ -z "$FLUXCD_PLUGINS" ]]; then
|
||||
FLUXCD_PLUGINS="${RUNNER_TOOL_CACHE}/flux2/${VERSION}/${OS}/${ARCH}/plugins/"
|
||||
fi
|
||||
|
||||
# Export it so subsequent steps can discover the installed plugins.
|
||||
echo "FLUXCD_PLUGINS=$FLUXCD_PLUGINS" >> "$GITHUB_ENV"
|
||||
|
||||
# Create the directory if it does not exist.
|
||||
mkdir -p "$FLUXCD_PLUGINS"
|
||||
|
||||
- name: "Install Plugins"
|
||||
shell: bash
|
||||
id: install-plugins
|
||||
if: steps.plugin-support-gate.outputs.plugin-support == 'yes'
|
||||
env:
|
||||
PLUGINS: ${{ inputs.plugins }}
|
||||
run: |
|
||||
echo "$PLUGINS" | while read -r PLUGIN; do
|
||||
trimmed="${PLUGIN//[[:space:]]/}"
|
||||
if [[ -n "$trimmed" ]]; then
|
||||
echo Installing: "$PLUGIN"
|
||||
flux plugin install "$PLUGIN"
|
||||
fi
|
||||
done
|
||||
|
||||
- name: "Set Output"
|
||||
id: set-outputs
|
||||
if: steps.plugin-support-gate.outputs.plugin-support == 'yes'
|
||||
shell: bash
|
||||
run: |
|
||||
echo "plugin-dir=$FLUXCD_PLUGINS" >> "$GITHUB_OUTPUT"
|
||||
|
||||
@@ -20,9 +20,11 @@ import (
|
||||
"context"
|
||||
"crypto/elliptic"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/fluxcd/pkg/git"
|
||||
"github.com/fluxcd/pkg/git/signature"
|
||||
"github.com/manifoldco/promptui"
|
||||
"github.com/spf13/cobra"
|
||||
"k8s.io/apimachinery/pkg/api/errors"
|
||||
@@ -30,6 +32,7 @@ import (
|
||||
|
||||
"github.com/fluxcd/flux2/v2/internal/flags"
|
||||
"github.com/fluxcd/flux2/v2/internal/utils"
|
||||
"github.com/fluxcd/flux2/v2/pkg/bootstrap"
|
||||
"github.com/fluxcd/flux2/v2/pkg/manifestgen"
|
||||
"github.com/fluxcd/flux2/v2/pkg/manifestgen/sourcesecret"
|
||||
)
|
||||
@@ -47,6 +50,7 @@ type bootstrapFlags struct {
|
||||
|
||||
branch string
|
||||
recurseSubmodules bool
|
||||
sparseCheckout []string
|
||||
manifestsPath string
|
||||
|
||||
defaultComponents []string
|
||||
@@ -79,6 +83,11 @@ type bootstrapFlags struct {
|
||||
gpgPassphrase string
|
||||
gpgKeyID string
|
||||
|
||||
sshSigningKeyFile string
|
||||
sshSigningPassword string
|
||||
sshSigningPassphrase string
|
||||
sshSigningReusePrivateKey bool
|
||||
|
||||
force bool
|
||||
|
||||
commitMessageAppendix string
|
||||
@@ -109,6 +118,8 @@ func init() {
|
||||
bootstrapCmd.PersistentFlags().StringVar(&bootstrapArgs.branch, "branch", bootstrapDefaultBranch, "Git branch")
|
||||
bootstrapCmd.PersistentFlags().BoolVar(&bootstrapArgs.recurseSubmodules, "recurse-submodules", false,
|
||||
"when enabled, configures the GitRepository source to initialize and include Git submodules in the artifact it produces")
|
||||
bootstrapCmd.PersistentFlags().StringSliceVar(&bootstrapArgs.sparseCheckout, "sparse-checkout", nil,
|
||||
"list of directories to be included in the GitRepository sparse checkout, the configured --path must be one of them, accepts comma-separated values")
|
||||
|
||||
bootstrapCmd.PersistentFlags().StringVar(&bootstrapArgs.manifestsPath, "manifests", "", "path to the manifest directory")
|
||||
|
||||
@@ -139,6 +150,12 @@ func init() {
|
||||
bootstrapCmd.PersistentFlags().StringVar(&bootstrapArgs.gpgPassphrase, "gpg-passphrase", "", "passphrase for decrypting GPG private key")
|
||||
bootstrapCmd.PersistentFlags().StringVar(&bootstrapArgs.gpgKeyID, "gpg-key-id", "", "key id for selecting a particular key")
|
||||
|
||||
bootstrapCmd.PersistentFlags().StringVar(&bootstrapArgs.sshSigningKeyFile, "ssh-signing-key-file", "", "path to an SSH private key file used for signing commits")
|
||||
bootstrapCmd.PersistentFlags().StringVar(&bootstrapArgs.sshSigningPassword, "ssh-signing-password", "", "passphrase for decrypting SSH signing key")
|
||||
bootstrapCmd.PersistentFlags().StringVar(&bootstrapArgs.sshSigningPassphrase, "ssh-signing-passphrase", "", "alias for --ssh-signing-password")
|
||||
bootstrapCmd.PersistentFlags().MarkHidden("ssh-signing-passphrase")
|
||||
bootstrapCmd.PersistentFlags().BoolVar(&bootstrapArgs.sshSigningReusePrivateKey, "ssh-signing-reuse-private-key", false, "use the SSH transport key (--private-key-file) to sign commits")
|
||||
|
||||
bootstrapCmd.PersistentFlags().StringVar(&bootstrapArgs.commitMessageAppendix, "commit-message-appendix", "", "string to add to the commit messages, e.g. '[ci skip]'")
|
||||
|
||||
bootstrapCmd.PersistentFlags().BoolVar(&bootstrapArgs.force, "force", false, "override existing Flux installation if it's managed by a different tool such as Helm")
|
||||
@@ -195,6 +212,31 @@ func bootstrapValidate() error {
|
||||
return fmt.Errorf("invalid --registry-creds format, expected 'user:password'")
|
||||
}
|
||||
|
||||
sshSigningSet := bootstrapArgs.sshSigningKeyFile != "" || bootstrapArgs.sshSigningReusePrivateKey
|
||||
if bootstrapArgs.gpgKeyRingPath != "" && sshSigningSet {
|
||||
return fmt.Errorf("--gpg-* and --ssh-signing-* are mutually exclusive; pick one signing format")
|
||||
}
|
||||
|
||||
if bootstrapArgs.sshSigningKeyFile != "" && bootstrapArgs.sshSigningReusePrivateKey {
|
||||
return fmt.Errorf("--ssh-signing-key-file and --ssh-signing-reuse-private-key are mutually exclusive")
|
||||
}
|
||||
|
||||
if bootstrapArgs.sshSigningReusePrivateKey && bootstrapArgs.privateKeyFile == "" {
|
||||
return fmt.Errorf("--ssh-signing-reuse-private-key requires --private-key-file")
|
||||
}
|
||||
|
||||
sshSigningPwd, err := effectiveSshSigningPassword()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if sshSigningPwd != "" && bootstrapArgs.sshSigningKeyFile == "" {
|
||||
return fmt.Errorf("--ssh-signing-password requires --ssh-signing-key-file")
|
||||
}
|
||||
|
||||
if err := preflightSigningKey(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(bootstrapArgs.sshHostKeyAlgorithms) > 0 {
|
||||
git.HostKeyAlgos = bootstrapArgs.sshHostKeyAlgorithms
|
||||
}
|
||||
@@ -214,6 +256,57 @@ func mapTeamSlice(s []string, defaultPermission string) map[string]string {
|
||||
return m
|
||||
}
|
||||
|
||||
// preflightSigningKey reads and parses the configured signing key so
|
||||
// malformed PEM, wrong passphrases, and unsupported SSH algorithms
|
||||
// surface before any clone runs.
|
||||
func preflightSigningKey() error {
|
||||
switch {
|
||||
case bootstrapArgs.gpgKeyRingPath != "":
|
||||
ring, err := bootstrap.LoadEntityListFromPath(bootstrapArgs.gpgKeyRingPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid GPG signing key: %w", err)
|
||||
}
|
||||
if _, err := bootstrap.SelectOpenPGPSigningEntity(ring, bootstrapArgs.gpgPassphrase, bootstrapArgs.gpgKeyID); err != nil {
|
||||
return fmt.Errorf("invalid GPG signing key: %w", err)
|
||||
}
|
||||
case bootstrapArgs.sshSigningKeyFile != "":
|
||||
pemBytes, err := os.ReadFile(bootstrapArgs.sshSigningKeyFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read SSH signing key file: %w", err)
|
||||
}
|
||||
pwd, err := effectiveSshSigningPassword()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := signature.NewSSHSigner(pemBytes, []byte(pwd)); err != nil {
|
||||
return fmt.Errorf("invalid SSH signing key: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// effectiveSshSigningPassword resolves the SSH signing-key passphrase
|
||||
// from --ssh-signing-password and its hidden alias
|
||||
// --ssh-signing-passphrase. When both are set with the same value, the
|
||||
// value is returned. When both are set with different non-empty values,
|
||||
// an error is returned. When neither is set, an empty string is
|
||||
// returned with no error.
|
||||
func effectiveSshSigningPassword() (string, error) {
|
||||
pw := bootstrapArgs.sshSigningPassword
|
||||
alias := bootstrapArgs.sshSigningPassphrase
|
||||
switch {
|
||||
case pw != "" && alias != "":
|
||||
if pw != alias {
|
||||
return "", fmt.Errorf("--ssh-signing-password and --ssh-signing-passphrase are aliases; do not pass both")
|
||||
}
|
||||
return pw, nil
|
||||
case pw == "" && alias != "":
|
||||
return alias, nil
|
||||
default:
|
||||
return pw, nil
|
||||
}
|
||||
}
|
||||
|
||||
// confirmBootstrap gets a confirmation for running bootstrap over an existing Flux installation.
|
||||
// It returns a nil error if Flux is not installed or the user confirms overriding an existing installation
|
||||
func confirmBootstrap(ctx context.Context, kubeClient client.Client) error {
|
||||
|
||||
@@ -24,6 +24,7 @@ import (
|
||||
|
||||
"github.com/fluxcd/pkg/git"
|
||||
"github.com/fluxcd/pkg/git/gogit"
|
||||
"github.com/fluxcd/pkg/git/signature"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/fluxcd/flux2/v2/internal/flags"
|
||||
@@ -253,6 +254,7 @@ func bootstrapBServerCmdRun(cmd *cobra.Command, args []string) error {
|
||||
TargetPath: bServerArgs.path.ToSlash(),
|
||||
ManifestFile: sync.MakeDefaultOptions().ManifestFile,
|
||||
RecurseSubmodules: bootstrapArgs.recurseSubmodules,
|
||||
SparseCheckout: bootstrapArgs.sparseCheckout,
|
||||
}
|
||||
|
||||
entityList, err := bootstrap.LoadEntityListFromPath(bootstrapArgs.gpgKeyRingPath)
|
||||
@@ -287,6 +289,31 @@ func bootstrapBServerCmdRun(cmd *cobra.Command, args []string) error {
|
||||
bootstrapOpts = append(bootstrapOpts, bootstrap.WithReconcile())
|
||||
}
|
||||
|
||||
if bootstrapArgs.sshSigningKeyFile != "" {
|
||||
pemBytes, err := os.ReadFile(bootstrapArgs.sshSigningKeyFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read SSH signing key file: %w", err)
|
||||
}
|
||||
pwd, err := effectiveSshSigningPassword()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
bootstrapOpts = append(bootstrapOpts,
|
||||
bootstrap.WithSSHCommitSigning(pemBytes, []byte(pwd)))
|
||||
}
|
||||
|
||||
if bootstrapArgs.sshSigningReusePrivateKey {
|
||||
pemBytes, err := os.ReadFile(bootstrapArgs.privateKeyFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read transport private key for signing: %w", err)
|
||||
}
|
||||
if _, err := signature.NewSSHSigner(pemBytes, []byte(gitArgs.password)); err != nil {
|
||||
return fmt.Errorf("invalid signing key (reused from --private-key-file): %w", err)
|
||||
}
|
||||
bootstrapOpts = append(bootstrapOpts,
|
||||
bootstrap.WithSSHCommitSigning(pemBytes, []byte(gitArgs.password)))
|
||||
}
|
||||
|
||||
// Setup bootstrapper with constructed configs
|
||||
b, err := bootstrap.NewGitProviderBootstrapper(gitClient, providerClient, kubeClient, bootstrapOpts...)
|
||||
if err != nil {
|
||||
|
||||
@@ -28,8 +28,12 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
|
||||
"github.com/fluxcd/pkg/auth"
|
||||
"github.com/fluxcd/pkg/auth/aws"
|
||||
authutils "github.com/fluxcd/pkg/auth/utils"
|
||||
"github.com/fluxcd/pkg/git"
|
||||
"github.com/fluxcd/pkg/git/gogit"
|
||||
"github.com/fluxcd/pkg/git/signature"
|
||||
|
||||
"github.com/fluxcd/flux2/v2/internal/flags"
|
||||
"github.com/fluxcd/flux2/v2/internal/utils"
|
||||
@@ -62,9 +66,12 @@ command will perform an upgrade if needed.`,
|
||||
# Run bootstrap for a Git repository with a private key and password
|
||||
flux bootstrap git --url=ssh://git@example.com/repository.git --private-key-file=<path/to/private.key> --password=<password> --path=clusters/my-cluster
|
||||
|
||||
# Run bootstrap for a Git repository on AWS CodeCommit
|
||||
# Run bootstrap for a Git repository on AWS CodeCommit using SSH
|
||||
flux bootstrap git --url=ssh://<SSH-Key-ID>@git-codecommit.<region>.amazonaws.com/v1/repos/<repository> --private-key-file=<path/to/private.key> --password=<SSH-passphrase> --path=clusters/my-cluster
|
||||
|
||||
# Run bootstrap for a Git repository on AWS CodeCommit using HTTPS (requires AWS IAM credentials)
|
||||
flux bootstrap git --url=https://git-codecommit.<region>.amazonaws.com/v1/repos/<repository> --path=clusters/my-cluster
|
||||
|
||||
# Run bootstrap for a Git repository on Azure Devops
|
||||
flux bootstrap git --url=ssh://git@ssh.dev.azure.com/v3/<org>/<project>/<repository> --private-key-file=<path/to/rsa-sha2-private.key> --ssh-hostkey-algos=rsa-sha2-512,rsa-sha2-256 --path=clusters/my-cluster
|
||||
|
||||
@@ -109,6 +116,7 @@ func bootstrapGitCmdRun(cmd *cobra.Command, args []string) error {
|
||||
bootstrapArgs.tokenAuth = true
|
||||
}
|
||||
|
||||
var gitProvider string
|
||||
gitPassword := os.Getenv(gitPasswordEnvVar)
|
||||
if gitPassword != "" && gitArgs.password == "" {
|
||||
gitArgs.password = gitPassword
|
||||
@@ -131,8 +139,12 @@ func bootstrapGitCmdRun(cmd *cobra.Command, args []string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), rootArgs.timeout)
|
||||
defer cancel()
|
||||
|
||||
if strings.Contains(repositoryURL.Hostname(), "git-codecommit") && strings.Contains(repositoryURL.Hostname(), "amazonaws.com") {
|
||||
if repositoryURL.Scheme == string(git.SSH) {
|
||||
// https://docs.aws.amazon.com/codecommit/latest/userguide/auth-and-access-control.html
|
||||
if repositoryURL.Scheme == string(git.SSH) { // IAM user + SSH
|
||||
if repositoryURL.User == nil {
|
||||
return fmt.Errorf("invalid AWS CodeCommit url: ssh username should be specified in the url")
|
||||
}
|
||||
@@ -142,14 +154,18 @@ func bootstrapGitCmdRun(cmd *cobra.Command, args []string) error {
|
||||
if bootstrapArgs.privateKeyFile == "" {
|
||||
return fmt.Errorf("private key file is required for bootstrapping against AWS CodeCommit using ssh")
|
||||
}
|
||||
} else if repositoryURL.Scheme == string(git.HTTPS) && !bootstrapArgs.tokenAuth { // IAM role + HTTPS
|
||||
creds, err := authutils.GetGitCredentials(ctx, "aws", auth.WithGitURL(*repositoryURL))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get AWS CodeCommit IAM git credentials: %w", err)
|
||||
}
|
||||
gitArgs.username = creds.Username
|
||||
gitArgs.password = creds.Password
|
||||
bootstrapArgs.tokenAuth = true
|
||||
gitProvider = aws.ProviderName
|
||||
}
|
||||
if repositoryURL.Scheme == string(git.HTTPS) && !bootstrapArgs.tokenAuth {
|
||||
return fmt.Errorf("--token-auth=true must be specified for using an HTTPS AWS CodeCommit url")
|
||||
}
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), rootArgs.timeout)
|
||||
defer cancel()
|
||||
}
|
||||
|
||||
kubeClient, err := utils.KubeClient(kubeconfigArgs, kubeclientOptions)
|
||||
if err != nil {
|
||||
@@ -296,6 +312,10 @@ func bootstrapGitCmdRun(cmd *cobra.Command, args []string) error {
|
||||
TargetPath: gitArgs.path.ToSlash(),
|
||||
ManifestFile: sync.MakeDefaultOptions().ManifestFile,
|
||||
RecurseSubmodules: bootstrapArgs.recurseSubmodules,
|
||||
SparseCheckout: bootstrapArgs.sparseCheckout,
|
||||
}
|
||||
if gitProvider != "" {
|
||||
syncOpts.Provider = gitProvider
|
||||
}
|
||||
|
||||
entityList, err := bootstrap.LoadEntityListFromPath(bootstrapArgs.gpgKeyRingPath)
|
||||
@@ -315,6 +335,33 @@ func bootstrapGitCmdRun(cmd *cobra.Command, args []string) error {
|
||||
bootstrap.WithGitCommitSigning(entityList, bootstrapArgs.gpgPassphrase, bootstrapArgs.gpgKeyID),
|
||||
}
|
||||
|
||||
if bootstrapArgs.sshSigningKeyFile != "" {
|
||||
pemBytes, err := os.ReadFile(bootstrapArgs.sshSigningKeyFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read SSH signing key file: %w", err)
|
||||
}
|
||||
pwd, err := effectiveSshSigningPassword()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
bootstrapOpts = append(bootstrapOpts,
|
||||
bootstrap.WithSSHCommitSigning(pemBytes, []byte(pwd)))
|
||||
}
|
||||
|
||||
if bootstrapArgs.sshSigningReusePrivateKey {
|
||||
pemBytes, err := os.ReadFile(bootstrapArgs.privateKeyFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read transport private key for signing: %w", err)
|
||||
}
|
||||
// Reuse-path pre-flight: bootstrapValidate cannot run this check
|
||||
// because the SSH transport password is subcommand-local.
|
||||
if _, err := signature.NewSSHSigner(pemBytes, []byte(gitArgs.password)); err != nil {
|
||||
return fmt.Errorf("invalid signing key (reused from --private-key-file): %w", err)
|
||||
}
|
||||
bootstrapOpts = append(bootstrapOpts,
|
||||
bootstrap.WithSSHCommitSigning(pemBytes, []byte(gitArgs.password)))
|
||||
}
|
||||
|
||||
// Setup bootstrapper with constructed configs
|
||||
b, err := bootstrap.NewPlainGitProvider(gitClient, kubeClient, bootstrapOpts...)
|
||||
if err != nil {
|
||||
|
||||
@@ -107,6 +107,11 @@ func init() {
|
||||
}
|
||||
|
||||
func bootstrapGiteaCmdRun(cmd *cobra.Command, args []string) error {
|
||||
if bootstrapArgs.sshSigningReusePrivateKey {
|
||||
return fmt.Errorf("--ssh-signing-reuse-private-key is not supported by 'bootstrap gitea'; " +
|
||||
"that subcommand generates the SSH transport key in-process and has no operator-supplied key to reuse")
|
||||
}
|
||||
|
||||
gtToken := os.Getenv(gtTokenEnvVar)
|
||||
if gtToken == "" {
|
||||
var err error
|
||||
@@ -232,6 +237,7 @@ func bootstrapGiteaCmdRun(cmd *cobra.Command, args []string) error {
|
||||
TargetPath: giteaArgs.path.ToSlash(),
|
||||
ManifestFile: sync.MakeDefaultOptions().ManifestFile,
|
||||
RecurseSubmodules: bootstrapArgs.recurseSubmodules,
|
||||
SparseCheckout: bootstrapArgs.sparseCheckout,
|
||||
}
|
||||
|
||||
entityList, err := bootstrap.LoadEntityListFromPath(bootstrapArgs.gpgKeyRingPath)
|
||||
@@ -252,6 +258,7 @@ func bootstrapGiteaCmdRun(cmd *cobra.Command, args []string) error {
|
||||
bootstrap.WithLogger(logger),
|
||||
bootstrap.WithGitCommitSigning(entityList, bootstrapArgs.gpgPassphrase, bootstrapArgs.gpgKeyID),
|
||||
}
|
||||
|
||||
if bootstrapArgs.sshHostname != "" {
|
||||
bootstrapOpts = append(bootstrapOpts, bootstrap.WithSSHHostname(bootstrapArgs.sshHostname))
|
||||
}
|
||||
@@ -265,6 +272,19 @@ func bootstrapGiteaCmdRun(cmd *cobra.Command, args []string) error {
|
||||
bootstrapOpts = append(bootstrapOpts, bootstrap.WithReconcile())
|
||||
}
|
||||
|
||||
if bootstrapArgs.sshSigningKeyFile != "" {
|
||||
pemBytes, err := os.ReadFile(bootstrapArgs.sshSigningKeyFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read SSH signing key file: %w", err)
|
||||
}
|
||||
pwd, err := effectiveSshSigningPassword()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
bootstrapOpts = append(bootstrapOpts,
|
||||
bootstrap.WithSSHCommitSigning(pemBytes, []byte(pwd)))
|
||||
}
|
||||
|
||||
// Setup bootstrapper with constructed configs
|
||||
b, err := bootstrap.NewGitProviderBootstrapper(gitClient, providerClient, kubeClient, bootstrapOpts...)
|
||||
if err != nil {
|
||||
|
||||
@@ -107,6 +107,11 @@ func init() {
|
||||
}
|
||||
|
||||
func bootstrapGitHubCmdRun(cmd *cobra.Command, args []string) error {
|
||||
if bootstrapArgs.sshSigningReusePrivateKey {
|
||||
return fmt.Errorf("--ssh-signing-reuse-private-key is not supported by 'bootstrap github'; " +
|
||||
"that subcommand generates the SSH transport key in-process and has no operator-supplied key to reuse")
|
||||
}
|
||||
|
||||
ghToken := os.Getenv(ghTokenEnvVar)
|
||||
if ghToken == "" {
|
||||
var err error
|
||||
@@ -239,6 +244,7 @@ func bootstrapGitHubCmdRun(cmd *cobra.Command, args []string) error {
|
||||
TargetPath: githubArgs.path.ToSlash(),
|
||||
ManifestFile: sync.MakeDefaultOptions().ManifestFile,
|
||||
RecurseSubmodules: bootstrapArgs.recurseSubmodules,
|
||||
SparseCheckout: bootstrapArgs.sparseCheckout,
|
||||
}
|
||||
|
||||
entityList, err := bootstrap.LoadEntityListFromPath(bootstrapArgs.gpgKeyRingPath)
|
||||
@@ -259,6 +265,7 @@ func bootstrapGitHubCmdRun(cmd *cobra.Command, args []string) error {
|
||||
bootstrap.WithLogger(logger),
|
||||
bootstrap.WithGitCommitSigning(entityList, bootstrapArgs.gpgPassphrase, bootstrapArgs.gpgKeyID),
|
||||
}
|
||||
|
||||
if bootstrapArgs.sshHostname != "" {
|
||||
bootstrapOpts = append(bootstrapOpts, bootstrap.WithSSHHostname(bootstrapArgs.sshHostname))
|
||||
}
|
||||
@@ -272,6 +279,19 @@ func bootstrapGitHubCmdRun(cmd *cobra.Command, args []string) error {
|
||||
bootstrapOpts = append(bootstrapOpts, bootstrap.WithReconcile())
|
||||
}
|
||||
|
||||
if bootstrapArgs.sshSigningKeyFile != "" {
|
||||
pemBytes, err := os.ReadFile(bootstrapArgs.sshSigningKeyFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read SSH signing key file: %w", err)
|
||||
}
|
||||
pwd, err := effectiveSshSigningPassword()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
bootstrapOpts = append(bootstrapOpts,
|
||||
bootstrap.WithSSHCommitSigning(pemBytes, []byte(pwd)))
|
||||
}
|
||||
|
||||
// Setup bootstrapper with constructed configs
|
||||
b, err := bootstrap.NewGitProviderBootstrapper(gitClient, providerClient, kubeClient, bootstrapOpts...)
|
||||
if err != nil {
|
||||
|
||||
@@ -27,6 +27,7 @@ import (
|
||||
"github.com/fluxcd/go-git-providers/gitprovider"
|
||||
"github.com/fluxcd/pkg/git"
|
||||
"github.com/fluxcd/pkg/git/gogit"
|
||||
"github.com/fluxcd/pkg/git/signature"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/fluxcd/flux2/v2/internal/flags"
|
||||
@@ -287,6 +288,7 @@ func bootstrapGitLabCmdRun(cmd *cobra.Command, args []string) error {
|
||||
TargetPath: gitlabArgs.path.ToSlash(),
|
||||
ManifestFile: sync.MakeDefaultOptions().ManifestFile,
|
||||
RecurseSubmodules: bootstrapArgs.recurseSubmodules,
|
||||
SparseCheckout: bootstrapArgs.sparseCheckout,
|
||||
}
|
||||
|
||||
entityList, err := bootstrap.LoadEntityListFromPath(bootstrapArgs.gpgKeyRingPath)
|
||||
@@ -321,6 +323,31 @@ func bootstrapGitLabCmdRun(cmd *cobra.Command, args []string) error {
|
||||
bootstrapOpts = append(bootstrapOpts, bootstrap.WithReconcile())
|
||||
}
|
||||
|
||||
if bootstrapArgs.sshSigningKeyFile != "" {
|
||||
pemBytes, err := os.ReadFile(bootstrapArgs.sshSigningKeyFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read SSH signing key file: %w", err)
|
||||
}
|
||||
pwd, err := effectiveSshSigningPassword()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
bootstrapOpts = append(bootstrapOpts,
|
||||
bootstrap.WithSSHCommitSigning(pemBytes, []byte(pwd)))
|
||||
}
|
||||
|
||||
if bootstrapArgs.sshSigningReusePrivateKey {
|
||||
pemBytes, err := os.ReadFile(bootstrapArgs.privateKeyFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read transport private key for signing: %w", err)
|
||||
}
|
||||
if _, err := signature.NewSSHSigner(pemBytes, []byte(gitArgs.password)); err != nil {
|
||||
return fmt.Errorf("invalid signing key (reused from --private-key-file): %w", err)
|
||||
}
|
||||
bootstrapOpts = append(bootstrapOpts,
|
||||
bootstrap.WithSSHCommitSigning(pemBytes, []byte(gitArgs.password)))
|
||||
}
|
||||
|
||||
// Setup bootstrapper with constructed configs
|
||||
b, err := bootstrap.NewGitProviderBootstrapper(gitClient, providerClient, kubeClient, bootstrapOpts...)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
/*
|
||||
Copyright 2026 The Flux authors
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBootstrapValidate_signingFlags(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
gpgRing string
|
||||
gpgPass string
|
||||
sshKey string
|
||||
sshPass string
|
||||
sshPassp string
|
||||
privateKey string
|
||||
reuse bool
|
||||
wantErr string
|
||||
}{
|
||||
{name: "no signing flags is valid"},
|
||||
{name: "GPG only is valid", gpgRing: "./testdata/bootstrap/gpg.pgp"},
|
||||
{name: "SSH only is valid", sshKey: "./testdata/bootstrap/ed25519.private"},
|
||||
{
|
||||
name: "Reuse-private-key with private-key-file is valid",
|
||||
privateKey: "./testdata/bootstrap/ed25519.private",
|
||||
reuse: true,
|
||||
},
|
||||
{
|
||||
name: "GPG + SSH errors",
|
||||
gpgRing: "./testdata/bootstrap/gpg.pgp",
|
||||
sshKey: "./testdata/bootstrap/ed25519.private",
|
||||
wantErr: "--gpg-* and --ssh-signing-* are mutually exclusive",
|
||||
},
|
||||
{
|
||||
name: "GPG + reuse errors",
|
||||
gpgRing: "./testdata/bootstrap/gpg.pgp",
|
||||
privateKey: "./testdata/bootstrap/ed25519.private",
|
||||
reuse: true,
|
||||
wantErr: "--gpg-* and --ssh-signing-* are mutually exclusive",
|
||||
},
|
||||
{
|
||||
name: "SSH key-file + reuse errors",
|
||||
sshKey: "./testdata/bootstrap/ed25519.private",
|
||||
privateKey: "./testdata/bootstrap/ed25519.private",
|
||||
reuse: true,
|
||||
wantErr: "--ssh-signing-key-file and --ssh-signing-reuse-private-key are mutually exclusive",
|
||||
},
|
||||
{
|
||||
name: "Reuse without private-key-file errors",
|
||||
reuse: true,
|
||||
wantErr: "--ssh-signing-reuse-private-key requires --private-key-file",
|
||||
},
|
||||
{
|
||||
name: "SSH password without key errors",
|
||||
sshPass: "secret",
|
||||
wantErr: "--ssh-signing-password requires --ssh-signing-key-file",
|
||||
},
|
||||
{
|
||||
name: "SSH passphrase alias alone applies",
|
||||
sshKey: "./testdata/bootstrap/ed25519-encrypted.private",
|
||||
sshPassp: "abcde12345",
|
||||
},
|
||||
{
|
||||
name: "SSH password and passphrase with same value passes",
|
||||
sshKey: "./testdata/bootstrap/ed25519-encrypted.private",
|
||||
sshPass: "abcde12345",
|
||||
sshPassp: "abcde12345",
|
||||
},
|
||||
{
|
||||
name: "SSH password and passphrase with different values errors",
|
||||
sshKey: "./testdata/bootstrap/ed25519-encrypted.private",
|
||||
sshPass: "right",
|
||||
sshPassp: "wrong",
|
||||
wantErr: "are aliases; do not pass both",
|
||||
},
|
||||
{
|
||||
name: "SSH malformed key fails pre-flight",
|
||||
sshKey: "./testdata/bootstrap/malformed.private",
|
||||
wantErr: "invalid SSH signing key",
|
||||
},
|
||||
{
|
||||
name: "SSH encrypted key without password fails pre-flight",
|
||||
sshKey: "./testdata/bootstrap/ed25519-encrypted.private",
|
||||
wantErr: "passphrase required",
|
||||
},
|
||||
// The GPG fixture used here is encrypted (passphrase: "right") so that
|
||||
// passing the wrong passphrase exercises the Decrypt error path.
|
||||
// An unencrypted key would make Decrypt a no-op regardless of the
|
||||
// passphrase supplied.
|
||||
{
|
||||
name: "GPG with wrong passphrase fails pre-flight",
|
||||
gpgRing: "./testdata/bootstrap/gpg-encrypted.pgp",
|
||||
gpgPass: "wrong",
|
||||
wantErr: "invalid GPG signing key",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
savedDefaultComponents := bootstrapArgs.defaultComponents
|
||||
savedGpgRing := bootstrapArgs.gpgKeyRingPath
|
||||
savedGpgPass := bootstrapArgs.gpgPassphrase
|
||||
savedSshKey := bootstrapArgs.sshSigningKeyFile
|
||||
savedSshPass := bootstrapArgs.sshSigningPassword
|
||||
savedSshPassp := bootstrapArgs.sshSigningPassphrase
|
||||
savedPrivKey := bootstrapArgs.privateKeyFile
|
||||
savedReuse := bootstrapArgs.sshSigningReusePrivateKey
|
||||
defer func() {
|
||||
bootstrapArgs.defaultComponents = savedDefaultComponents
|
||||
bootstrapArgs.gpgKeyRingPath = savedGpgRing
|
||||
bootstrapArgs.gpgPassphrase = savedGpgPass
|
||||
bootstrapArgs.sshSigningKeyFile = savedSshKey
|
||||
bootstrapArgs.sshSigningPassword = savedSshPass
|
||||
bootstrapArgs.sshSigningPassphrase = savedSshPassp
|
||||
bootstrapArgs.privateKeyFile = savedPrivKey
|
||||
bootstrapArgs.sshSigningReusePrivateKey = savedReuse
|
||||
}()
|
||||
|
||||
// The e2e TestMain calls resetCmdArgs which clears the
|
||||
// cobra-populated default components, so seed them here to
|
||||
// satisfy the requiredComponents pre-check in bootstrapValidate.
|
||||
bootstrapArgs.defaultComponents = bootstrapArgs.requiredComponents
|
||||
bootstrapArgs.gpgKeyRingPath = tt.gpgRing
|
||||
bootstrapArgs.gpgPassphrase = tt.gpgPass
|
||||
bootstrapArgs.sshSigningKeyFile = tt.sshKey
|
||||
bootstrapArgs.sshSigningPassword = tt.sshPass
|
||||
bootstrapArgs.sshSigningPassphrase = tt.sshPassp
|
||||
bootstrapArgs.privateKeyFile = tt.privateKey
|
||||
bootstrapArgs.sshSigningReusePrivateKey = tt.reuse
|
||||
|
||||
err := bootstrapValidate()
|
||||
if tt.wantErr == "" {
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatalf("expected error containing %q, got nil", tt.wantErr)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("expected error containing %q, got: %v", tt.wantErr, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Providers that generate the SSH transport key in-process (github, gitea)
|
||||
// must reject --ssh-signing-reuse-private-key with their own, provider-
|
||||
// specific error before bootstrapValidate runs — otherwise the generic
|
||||
// "--ssh-signing-reuse-private-key requires --private-key-file" error
|
||||
// shadows the fact that the flag is fundamentally unsupported there.
|
||||
func TestBootstrapProviderRejectsReuseBeforeValidate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
runE func() error
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "github rejects reuse with provider-specific error",
|
||||
runE: func() error { return bootstrapGitHubCmdRun(nil, nil) },
|
||||
wantErr: "not supported by 'bootstrap github'",
|
||||
},
|
||||
{
|
||||
name: "gitea rejects reuse with provider-specific error",
|
||||
runE: func() error { return bootstrapGiteaCmdRun(nil, nil) },
|
||||
wantErr: "not supported by 'bootstrap gitea'",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
savedReuse := bootstrapArgs.sshSigningReusePrivateKey
|
||||
savedPrivKey := bootstrapArgs.privateKeyFile
|
||||
defer func() {
|
||||
bootstrapArgs.sshSigningReusePrivateKey = savedReuse
|
||||
bootstrapArgs.privateKeyFile = savedPrivKey
|
||||
}()
|
||||
|
||||
// Reuse flag set, no --private-key-file: bootstrapValidate
|
||||
// would otherwise return "requires --private-key-file".
|
||||
bootstrapArgs.sshSigningReusePrivateKey = true
|
||||
bootstrapArgs.privateKeyFile = ""
|
||||
|
||||
err := tt.runE()
|
||||
if err == nil {
|
||||
t.Fatalf("expected error containing %q, got nil", tt.wantErr)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("expected error containing %q, got: %v", tt.wantErr, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+150
-3
@@ -22,6 +22,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
@@ -48,9 +49,10 @@ from the given directory or a single manifest file.`,
|
||||
}
|
||||
|
||||
type buildArtifactFlags struct {
|
||||
output string
|
||||
path string
|
||||
ignorePaths []string
|
||||
output string
|
||||
path string
|
||||
ignorePaths []string
|
||||
resolveSymlinks bool
|
||||
}
|
||||
|
||||
var excludeOCI = append(strings.Split(sourceignore.ExcludeVCS, ","), strings.Split(sourceignore.ExcludeExt, ",")...)
|
||||
@@ -61,6 +63,7 @@ func init() {
|
||||
buildArtifactCmd.Flags().StringVarP(&buildArtifactArgs.path, "path", "p", "", "Path to the directory where the Kubernetes manifests are located.")
|
||||
buildArtifactCmd.Flags().StringVarP(&buildArtifactArgs.output, "output", "o", "artifact.tgz", "Path to where the artifact tgz file should be written.")
|
||||
buildArtifactCmd.Flags().StringSliceVar(&buildArtifactArgs.ignorePaths, "ignore-paths", excludeOCI, "set paths to ignore in .gitignore format")
|
||||
buildArtifactCmd.Flags().BoolVar(&buildArtifactArgs.resolveSymlinks, "resolve-symlinks", false, "resolve symlinks by copying their targets into the artifact")
|
||||
|
||||
buildCmd.AddCommand(buildArtifactCmd)
|
||||
}
|
||||
@@ -85,6 +88,15 @@ func buildArtifactCmdRun(cmd *cobra.Command, args []string) error {
|
||||
return fmt.Errorf("invalid path '%s', must point to an existing directory or file", path)
|
||||
}
|
||||
|
||||
if buildArtifactArgs.resolveSymlinks {
|
||||
resolved, cleanupDir, err := resolveSymlinks(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolving symlinks failed: %w", err)
|
||||
}
|
||||
defer os.RemoveAll(cleanupDir)
|
||||
path = resolved
|
||||
}
|
||||
|
||||
logger.Actionf("building artifact from %s", path)
|
||||
|
||||
ociClient := oci.NewClient(oci.DefaultOptions())
|
||||
@@ -96,6 +108,141 @@ func buildArtifactCmdRun(cmd *cobra.Command, args []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveSymlinks creates a temporary directory with symlinks resolved to their
|
||||
// real file contents. This allows building artifacts from symlink trees (e.g.,
|
||||
// those created by Nix) where the actual files live outside the source directory.
|
||||
// It returns the resolved path and the temporary directory path for cleanup.
|
||||
func resolveSymlinks(srcPath string) (string, string, error) {
|
||||
absPath, err := filepath.Abs(srcPath)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
info, err := os.Stat(absPath)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
// For a single file, resolve the symlink and return the path to the
|
||||
// copied file within the temp dir, preserving file semantics for callers.
|
||||
if !info.IsDir() {
|
||||
resolved, err := filepath.EvalSymlinks(absPath)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("resolving symlink for %s: %w", absPath, err)
|
||||
}
|
||||
tmpDir, err := os.MkdirTemp("", "flux-artifact-*")
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
dst := filepath.Join(tmpDir, filepath.Base(absPath))
|
||||
if err := copyFile(resolved, dst); err != nil {
|
||||
os.RemoveAll(tmpDir)
|
||||
return "", "", err
|
||||
}
|
||||
return dst, tmpDir, nil
|
||||
}
|
||||
|
||||
tmpDir, err := os.MkdirTemp("", "flux-artifact-*")
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
visited := make(map[string]bool)
|
||||
if err := copyDir(absPath, tmpDir, visited); err != nil {
|
||||
os.RemoveAll(tmpDir)
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
return tmpDir, tmpDir, nil
|
||||
}
|
||||
|
||||
// copyDir recursively copies the contents of srcDir to dstDir, resolving any
|
||||
// symlinks encountered along the way. The visited map tracks resolved real
|
||||
// directory paths to detect and break symlink cycles.
|
||||
func copyDir(srcDir, dstDir string, visited map[string]bool) error {
|
||||
real, err := filepath.EvalSymlinks(srcDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolving symlink %s: %w", srcDir, err)
|
||||
}
|
||||
abs, err := filepath.Abs(real)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting absolute path for %s: %w", real, err)
|
||||
}
|
||||
if visited[abs] {
|
||||
return nil // break the cycle
|
||||
}
|
||||
visited[abs] = true
|
||||
defer delete(visited, abs)
|
||||
entries, err := os.ReadDir(srcDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
srcPath := filepath.Join(srcDir, entry.Name())
|
||||
dstPath := filepath.Join(dstDir, entry.Name())
|
||||
|
||||
// Resolve symlinks to get the real path and info.
|
||||
realPath, err := filepath.EvalSymlinks(srcPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolving symlink %s: %w", srcPath, err)
|
||||
}
|
||||
realInfo, err := os.Stat(realPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("stat resolved path %s: %w", realPath, err)
|
||||
}
|
||||
|
||||
if realInfo.IsDir() {
|
||||
if err := os.MkdirAll(dstPath, realInfo.Mode()); err != nil {
|
||||
return err
|
||||
}
|
||||
// Recursively copy the resolved directory contents.
|
||||
if err := copyDir(realPath, dstPath, visited); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if !realInfo.Mode().IsRegular() {
|
||||
continue
|
||||
}
|
||||
|
||||
if err := copyFile(realPath, dstPath); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func copyFile(src, dst string) error {
|
||||
srcInfo, err := os.Stat(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, srcInfo.Mode())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
if _, err := io.Copy(out, in); err != nil {
|
||||
return err
|
||||
}
|
||||
return out.Close()
|
||||
}
|
||||
|
||||
func saveReaderToFile(reader io.Reader) (string, error) {
|
||||
b, err := io.ReadAll(bufio.NewReader(reader))
|
||||
if err != nil {
|
||||
|
||||
@@ -18,6 +18,7 @@ package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -68,3 +69,149 @@ data:
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
func Test_resolveSymlinks(t *testing.T) {
|
||||
g := NewWithT(t)
|
||||
|
||||
// Create source directory with a real file
|
||||
srcDir := t.TempDir()
|
||||
realFile := filepath.Join(srcDir, "real.yaml")
|
||||
g.Expect(os.WriteFile(realFile, []byte("apiVersion: v1\nkind: Namespace\nmetadata:\n name: test\n"), 0o644)).To(Succeed())
|
||||
|
||||
// Create a directory with symlinks pointing to files outside it
|
||||
symlinkDir := t.TempDir()
|
||||
symlinkFile := filepath.Join(symlinkDir, "linked.yaml")
|
||||
g.Expect(os.Symlink(realFile, symlinkFile)).To(Succeed())
|
||||
|
||||
// Also add a regular file in the symlink dir
|
||||
regularFile := filepath.Join(symlinkDir, "regular.yaml")
|
||||
g.Expect(os.WriteFile(regularFile, []byte("apiVersion: v1\nkind: ConfigMap\n"), 0o644)).To(Succeed())
|
||||
|
||||
// Create a symlinked subdirectory
|
||||
subDir := filepath.Join(srcDir, "subdir")
|
||||
g.Expect(os.MkdirAll(subDir, 0o755)).To(Succeed())
|
||||
g.Expect(os.WriteFile(filepath.Join(subDir, "nested.yaml"), []byte("nested"), 0o644)).To(Succeed())
|
||||
g.Expect(os.Symlink(subDir, filepath.Join(symlinkDir, "linkeddir"))).To(Succeed())
|
||||
|
||||
// Resolve symlinks
|
||||
resolved, cleanupDir, err := resolveSymlinks(symlinkDir)
|
||||
g.Expect(err).To(BeNil())
|
||||
t.Cleanup(func() { os.RemoveAll(cleanupDir) })
|
||||
|
||||
// Verify the regular file was copied
|
||||
content, err := os.ReadFile(filepath.Join(resolved, "regular.yaml"))
|
||||
g.Expect(err).To(BeNil())
|
||||
g.Expect(string(content)).To(Equal("apiVersion: v1\nkind: ConfigMap\n"))
|
||||
|
||||
// Verify the symlinked file was resolved and copied
|
||||
content, err = os.ReadFile(filepath.Join(resolved, "linked.yaml"))
|
||||
g.Expect(err).To(BeNil())
|
||||
g.Expect(string(content)).To(ContainSubstring("kind: Namespace"))
|
||||
|
||||
// Verify that the resolved file is a regular file, not a symlink
|
||||
info, err := os.Lstat(filepath.Join(resolved, "linked.yaml"))
|
||||
g.Expect(err).To(BeNil())
|
||||
g.Expect(info.Mode().IsRegular()).To(BeTrue())
|
||||
|
||||
// Verify that the symlinked directory was resolved and its contents were copied
|
||||
content, err = os.ReadFile(filepath.Join(resolved, "linkeddir", "nested.yaml"))
|
||||
g.Expect(err).To(BeNil())
|
||||
g.Expect(string(content)).To(Equal("nested"))
|
||||
|
||||
// Verify that the file inside the symlinked directory is a regular file
|
||||
info, err = os.Lstat(filepath.Join(resolved, "linkeddir", "nested.yaml"))
|
||||
g.Expect(err).To(BeNil())
|
||||
g.Expect(info.Mode().IsRegular()).To(BeTrue())
|
||||
}
|
||||
|
||||
func Test_resolveSymlinks_singleFile(t *testing.T) {
|
||||
g := NewWithT(t)
|
||||
|
||||
// Create a real file
|
||||
srcDir := t.TempDir()
|
||||
realFile := filepath.Join(srcDir, "manifest.yaml")
|
||||
g.Expect(os.WriteFile(realFile, []byte("kind: ConfigMap"), 0o644)).To(Succeed())
|
||||
|
||||
// Create a symlink to the real file
|
||||
linkDir := t.TempDir()
|
||||
linkFile := filepath.Join(linkDir, "link.yaml")
|
||||
g.Expect(os.Symlink(realFile, linkFile)).To(Succeed())
|
||||
|
||||
// Resolve the single symlinked file
|
||||
resolved, cleanupDir, err := resolveSymlinks(linkFile)
|
||||
g.Expect(err).To(BeNil())
|
||||
t.Cleanup(func() { os.RemoveAll(cleanupDir) })
|
||||
|
||||
// The returned path should be a file, not a directory
|
||||
info, err := os.Stat(resolved)
|
||||
g.Expect(err).To(BeNil())
|
||||
g.Expect(info.IsDir()).To(BeFalse())
|
||||
|
||||
// Verify contents
|
||||
content, err := os.ReadFile(resolved)
|
||||
g.Expect(err).To(BeNil())
|
||||
g.Expect(string(content)).To(Equal("kind: ConfigMap"))
|
||||
}
|
||||
|
||||
func Test_resolveSymlinks_cycle(t *testing.T) {
|
||||
g := NewWithT(t)
|
||||
|
||||
// Create a directory with a symlink cycle: dir/link -> dir
|
||||
dir := t.TempDir()
|
||||
g.Expect(os.WriteFile(filepath.Join(dir, "file.yaml"), []byte("data"), 0o644)).To(Succeed())
|
||||
g.Expect(os.Symlink(dir, filepath.Join(dir, "cycle"))).To(Succeed())
|
||||
|
||||
// resolveSymlinks should not infinite-loop
|
||||
resolved, cleanupDir, err := resolveSymlinks(dir)
|
||||
g.Expect(err).To(BeNil())
|
||||
t.Cleanup(func() { os.RemoveAll(cleanupDir) })
|
||||
|
||||
// The file should be copied
|
||||
content, err := os.ReadFile(filepath.Join(resolved, "file.yaml"))
|
||||
g.Expect(err).To(BeNil())
|
||||
g.Expect(string(content)).To(Equal("data"))
|
||||
|
||||
// The cycle directory should exist but not cause infinite nesting
|
||||
_, err = os.Stat(filepath.Join(resolved, "cycle"))
|
||||
g.Expect(err).To(BeNil())
|
||||
|
||||
// There should NOT be deeply nested cycle/cycle/cycle/... paths
|
||||
_, err = os.Stat(filepath.Join(resolved, "cycle", "cycle", "cycle"))
|
||||
g.Expect(os.IsNotExist(err)).To(BeTrue())
|
||||
}
|
||||
|
||||
func Test_resolveSymlinks_multipleLinksSameTarget(t *testing.T) {
|
||||
g := NewWithT(t)
|
||||
|
||||
// Create source directory with a real file inside a dir
|
||||
srcDir := t.TempDir()
|
||||
targetDir := filepath.Join(srcDir, "target")
|
||||
g.Expect(os.MkdirAll(targetDir, 0o755)).To(Succeed())
|
||||
g.Expect(os.WriteFile(filepath.Join(targetDir, "file.yaml"), []byte("data"), 0o644)).To(Succeed())
|
||||
|
||||
// Create a directory with multiple symlinks pointing to targetDir
|
||||
symlinkDir := t.TempDir()
|
||||
|
||||
// Link 1
|
||||
link1 := filepath.Join(symlinkDir, "link1")
|
||||
g.Expect(os.Symlink(targetDir, link1)).To(Succeed())
|
||||
|
||||
// Link 2
|
||||
link2 := filepath.Join(symlinkDir, "link2")
|
||||
g.Expect(os.Symlink(targetDir, link2)).To(Succeed())
|
||||
|
||||
// Resolve symlinks
|
||||
resolved, cleanupDir, err := resolveSymlinks(symlinkDir)
|
||||
g.Expect(err).To(BeNil())
|
||||
t.Cleanup(func() { os.RemoveAll(cleanupDir) })
|
||||
|
||||
// Verify link1 has the file
|
||||
content, err := os.ReadFile(filepath.Join(resolved, "link1", "file.yaml"))
|
||||
g.Expect(err).To(BeNil())
|
||||
g.Expect(string(content)).To(Equal("data"))
|
||||
|
||||
// Verify link2 ALSO has the file
|
||||
content2, err := os.ReadFile(filepath.Join(resolved, "link2", "file.yaml"))
|
||||
g.Expect(err).To(BeNil())
|
||||
g.Expect(string(content2)).To(Equal("data"))
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
@@ -71,6 +72,7 @@ type buildKsFlags struct {
|
||||
strictSubst bool
|
||||
recursive bool
|
||||
localSources map[string]string
|
||||
inMemoryBuild bool
|
||||
}
|
||||
|
||||
var buildKsArgs buildKsFlags
|
||||
@@ -84,6 +86,8 @@ func init() {
|
||||
"When enabled, the post build substitutions will fail if a var without a default value is declared in files but is missing from the input vars.")
|
||||
buildKsCmd.Flags().BoolVarP(&buildKsArgs.recursive, "recursive", "r", false, "Recursively build Kustomizations")
|
||||
buildKsCmd.Flags().StringToStringVar(&buildKsArgs.localSources, "local-sources", nil, "Comma-separated list of repositories in format: Kind/namespace/name=path")
|
||||
buildKsCmd.Flags().BoolVar(&buildKsArgs.inMemoryBuild, "in-memory-build", true,
|
||||
"Use in-memory filesystem during build.")
|
||||
buildCmd.AddCommand(buildKsCmd)
|
||||
}
|
||||
|
||||
@@ -97,6 +101,13 @@ func buildKsCmdRun(cmd *cobra.Command, args []string) (err error) {
|
||||
return fmt.Errorf("invalid resource path %q", buildKsArgs.path)
|
||||
}
|
||||
|
||||
// Normalize the path to handle Windows absolute and relative paths correctly
|
||||
buildKsArgs.path, err = filepath.Abs(buildKsArgs.path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to resolve absolute path: %w", err)
|
||||
}
|
||||
buildKsArgs.path = filepath.Clean(buildKsArgs.path)
|
||||
|
||||
if fs, err := os.Stat(buildKsArgs.path); err != nil || !fs.IsDir() {
|
||||
return fmt.Errorf("invalid resource path %q", buildKsArgs.path)
|
||||
}
|
||||
@@ -122,6 +133,7 @@ func buildKsCmdRun(cmd *cobra.Command, args []string) (err error) {
|
||||
build.WithStrictSubstitute(buildKsArgs.strictSubst),
|
||||
build.WithRecursive(buildKsArgs.recursive),
|
||||
build.WithLocalSources(buildKsArgs.localSources),
|
||||
build.WithInMemoryBuild(buildKsArgs.inMemoryBuild),
|
||||
)
|
||||
} else {
|
||||
builder, err = build.NewBuilder(name, buildKsArgs.path,
|
||||
@@ -132,6 +144,7 @@ func buildKsCmdRun(cmd *cobra.Command, args []string) (err error) {
|
||||
build.WithStrictSubstitute(buildKsArgs.strictSubst),
|
||||
build.WithRecursive(buildKsArgs.recursive),
|
||||
build.WithLocalSources(buildKsArgs.localSources),
|
||||
build.WithInMemoryBuild(buildKsArgs.inMemoryBuild),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -52,6 +52,12 @@ func TestBuildKustomization(t *testing.T) {
|
||||
resultFile: "./testdata/build-kustomization/podinfo-result.yaml",
|
||||
assertFunc: "assertGoldenTemplateFile",
|
||||
},
|
||||
{
|
||||
name: "build podinfo (on-disk)",
|
||||
args: "build kustomization podinfo --path ./testdata/build-kustomization/podinfo --in-memory-build=false",
|
||||
resultFile: "./testdata/build-kustomization/podinfo-result.yaml",
|
||||
assertFunc: "assertGoldenTemplateFile",
|
||||
},
|
||||
{
|
||||
name: "build podinfo without service",
|
||||
args: "build kustomization podinfo --path ./testdata/build-kustomization/delete-service",
|
||||
@@ -70,12 +76,24 @@ func TestBuildKustomization(t *testing.T) {
|
||||
resultFile: "./testdata/build-kustomization/podinfo-with-ignore-result.yaml",
|
||||
assertFunc: "assertGoldenTemplateFile",
|
||||
},
|
||||
{
|
||||
name: "build ignore (on-disk)",
|
||||
args: "build kustomization podinfo --path ./testdata/build-kustomization/ignore --ignore-paths \"!configmap.yaml,!secret.yaml\" --in-memory-build=false",
|
||||
resultFile: "./testdata/build-kustomization/podinfo-with-ignore-result.yaml",
|
||||
assertFunc: "assertGoldenTemplateFile",
|
||||
},
|
||||
{
|
||||
name: "build with recursive",
|
||||
args: "build kustomization podinfo --path ./testdata/build-kustomization/podinfo-with-my-app --recursive --local-sources GitRepository/default/podinfo=./testdata/build-kustomization",
|
||||
resultFile: "./testdata/build-kustomization/podinfo-with-my-app-result.yaml",
|
||||
assertFunc: "assertGoldenTemplateFile",
|
||||
},
|
||||
{
|
||||
name: "build with recursive (on-disk)",
|
||||
args: "build kustomization podinfo --path ./testdata/build-kustomization/podinfo-with-my-app --recursive --local-sources GitRepository/default/podinfo=./testdata/build-kustomization --in-memory-build=false",
|
||||
resultFile: "./testdata/build-kustomization/podinfo-with-my-app-result.yaml",
|
||||
assertFunc: "assertGoldenTemplateFile",
|
||||
},
|
||||
}
|
||||
|
||||
tmpl := map[string]string{
|
||||
@@ -145,6 +163,12 @@ spec:
|
||||
resultFile: "./testdata/build-kustomization/podinfo-result.yaml",
|
||||
assertFunc: "assertGoldenTemplateFile",
|
||||
},
|
||||
{
|
||||
name: "build podinfo (on-disk)",
|
||||
args: "build kustomization podinfo --kustomization-file " + tmpFile + " --path ./testdata/build-kustomization/podinfo --in-memory-build=false",
|
||||
resultFile: "./testdata/build-kustomization/podinfo-result.yaml",
|
||||
assertFunc: "assertGoldenTemplateFile",
|
||||
},
|
||||
{
|
||||
name: "build podinfo without service",
|
||||
args: "build kustomization podinfo --kustomization-file " + tmpFile + " --path ./testdata/build-kustomization/delete-service",
|
||||
@@ -175,6 +199,18 @@ spec:
|
||||
resultFile: "./testdata/build-kustomization/podinfo-with-my-app-result.yaml",
|
||||
assertFunc: "assertGoldenTemplateFile",
|
||||
},
|
||||
{
|
||||
name: "build with recursive (on-disk)",
|
||||
args: "build kustomization podinfo --kustomization-file " + tmpFile + " --path ./testdata/build-kustomization/podinfo-with-my-app --recursive --local-sources GitRepository/default/podinfo=./testdata/build-kustomization --in-memory-build=false",
|
||||
resultFile: "./testdata/build-kustomization/podinfo-with-my-app-result.yaml",
|
||||
assertFunc: "assertGoldenTemplateFile",
|
||||
},
|
||||
{
|
||||
name: "build with recursive in dry-run mode (on-disk)",
|
||||
args: "build kustomization podinfo --kustomization-file " + tmpFile + " --path ./testdata/build-kustomization/podinfo-with-my-app --recursive --local-sources GitRepository/default/podinfo=./testdata/build-kustomization --in-memory-build=false --dry-run",
|
||||
resultFile: "./testdata/build-kustomization/podinfo-with-my-app-result.yaml",
|
||||
assertFunc: "assertGoldenTemplateFile",
|
||||
},
|
||||
}
|
||||
|
||||
tmpl := map[string]string{
|
||||
@@ -218,3 +254,71 @@ spec:
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildKustomizationPathNormalization verifies that absolute and complex
|
||||
// paths are normalized to prevent path concatenation bugs (issue #5673).
|
||||
// Without normalization, paths could be duplicated like: /path/test/path/test/file
|
||||
func TestBuildKustomizationPathNormalization(t *testing.T) {
|
||||
// Get absolute path to testdata to test absolute path handling
|
||||
absTestDataPath, err := filepath.Abs("testdata/build-kustomization/podinfo")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get absolute path: %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
args string
|
||||
resultFile string
|
||||
assertFunc string
|
||||
}{
|
||||
{
|
||||
name: "build with absolute path",
|
||||
args: "build kustomization podinfo --path " + absTestDataPath,
|
||||
resultFile: "./testdata/build-kustomization/podinfo-result.yaml",
|
||||
assertFunc: "assertGoldenTemplateFile",
|
||||
},
|
||||
{
|
||||
name: "build with absolute path (on-disk)",
|
||||
args: "build kustomization podinfo --path " + absTestDataPath + " --in-memory-build=false",
|
||||
resultFile: "./testdata/build-kustomization/podinfo-result.yaml",
|
||||
assertFunc: "assertGoldenTemplateFile",
|
||||
},
|
||||
{
|
||||
name: "build with complex relative path (parent dir)",
|
||||
args: "build kustomization podinfo --path ./testdata/build-kustomization/../build-kustomization/podinfo",
|
||||
resultFile: "./testdata/build-kustomization/podinfo-result.yaml",
|
||||
assertFunc: "assertGoldenTemplateFile",
|
||||
},
|
||||
{
|
||||
name: "build with path containing redundant separators",
|
||||
args: "build kustomization podinfo --path ./testdata//build-kustomization//podinfo",
|
||||
resultFile: "./testdata/build-kustomization/podinfo-result.yaml",
|
||||
assertFunc: "assertGoldenTemplateFile",
|
||||
},
|
||||
}
|
||||
|
||||
tmpl := map[string]string{
|
||||
"fluxns": allocateNamespace("flux-system"),
|
||||
}
|
||||
setup(t, tmpl)
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var assert assertFunc
|
||||
|
||||
switch tt.assertFunc {
|
||||
case "assertGoldenTemplateFile":
|
||||
assert = assertGoldenTemplateFile(tt.resultFile, tmpl)
|
||||
case "assertError":
|
||||
assert = assertError(tt.resultFile)
|
||||
}
|
||||
|
||||
cmd := cmdTestCase{
|
||||
args: tt.args + " -n " + tmpl["fluxns"],
|
||||
assert: assert,
|
||||
}
|
||||
|
||||
cmd.runTestCmd(t)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -60,7 +60,7 @@ type checkFlags struct {
|
||||
}
|
||||
|
||||
var kubernetesConstraints = []string{
|
||||
">=1.32.0-0",
|
||||
">=1.33.0-0",
|
||||
}
|
||||
|
||||
var checkArgs checkFlags
|
||||
|
||||
@@ -182,6 +182,10 @@ func createHelmReleaseCmdRun(cmd *cobra.Command, args []string) error {
|
||||
return fmt.Errorf("chart or chart-ref is required")
|
||||
}
|
||||
|
||||
if helmReleaseArgs.chart != "" && helmReleaseArgs.chartRef != "" {
|
||||
return fmt.Errorf("cannot use --chart in combination with --chart-ref")
|
||||
}
|
||||
|
||||
sourceLabels, err := parseLabels()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -214,7 +218,7 @@ func createHelmReleaseCmdRun(cmd *cobra.Command, args []string) error {
|
||||
}
|
||||
|
||||
if len(helmReleaseArgs.dependsOn) > 0 {
|
||||
ls := utils.MakeDependsOn(helmReleaseArgs.dependsOn)
|
||||
ls := meta.MakeDependsOn(helmReleaseArgs.dependsOn)
|
||||
hrDependsOn := make([]helmv2.DependencyReference, 0, len(ls))
|
||||
for _, d := range ls {
|
||||
hrDependsOn = append(hrDependsOn, helmv2.DependencyReference{
|
||||
|
||||
@@ -42,6 +42,11 @@ func TestCreateHelmRelease(t *testing.T) {
|
||||
args: "create helmrelease podinfo --export",
|
||||
assert: assertError("chart or chart-ref is required"),
|
||||
},
|
||||
{
|
||||
name: "chart and chartRef used in combination",
|
||||
args: "create helmrelease podinfo --chart podinfo --chart-ref foobar/podinfo --export",
|
||||
assert: assertError("cannot use --chart in combination with --chart-ref"),
|
||||
},
|
||||
{
|
||||
name: "unknown source kind",
|
||||
args: "create helmrelease podinfo --source foobar/podinfo --chart podinfo --export",
|
||||
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
|
||||
autov1 "github.com/fluxcd/image-automation-controller/api/v1"
|
||||
"github.com/fluxcd/pkg/apis/meta"
|
||||
sourcev1 "github.com/fluxcd/source-controller/api/v1"
|
||||
)
|
||||
|
||||
@@ -75,6 +76,8 @@ type imageUpdateFlags struct {
|
||||
commitTemplate string
|
||||
authorName string
|
||||
authorEmail string
|
||||
signingKeySecret string
|
||||
signingKeyType string
|
||||
}
|
||||
|
||||
var imageUpdateArgs = imageUpdateFlags{}
|
||||
@@ -89,6 +92,8 @@ func init() {
|
||||
flags.StringVar(&imageUpdateArgs.commitTemplate, "commit-template", "", "a template for commit messages")
|
||||
flags.StringVar(&imageUpdateArgs.authorName, "author-name", "", "the name to use for commit author")
|
||||
flags.StringVar(&imageUpdateArgs.authorEmail, "author-email", "", "the email to use for commit author")
|
||||
flags.StringVar(&imageUpdateArgs.signingKeySecret, "signing-key-secret", "", "name of the Secret containing the signing key referenced in spec.git.commit.signingKey")
|
||||
flags.StringVar(&imageUpdateArgs.signingKeyType, "signing-key-type", "", "signing-key format: gpg or ssh (defaults to gpg when --signing-key-secret is set)")
|
||||
|
||||
createImageCmd.AddCommand(createImageUpdateCmd)
|
||||
}
|
||||
@@ -112,6 +117,15 @@ func createImageUpdateRun(cmd *cobra.Command, args []string) error {
|
||||
return fmt.Errorf("the author email is required (--author-email)")
|
||||
}
|
||||
|
||||
if imageUpdateArgs.signingKeyType != "" && imageUpdateArgs.signingKeySecret == "" {
|
||||
return fmt.Errorf("--signing-key-type requires --signing-key-secret")
|
||||
}
|
||||
if imageUpdateArgs.signingKeyType != "" &&
|
||||
imageUpdateArgs.signingKeyType != string(autov1.SigningKeyTypeGPG) &&
|
||||
imageUpdateArgs.signingKeyType != string(autov1.SigningKeyTypeSSH) {
|
||||
return fmt.Errorf("--signing-key-type must be one of: gpg, ssh")
|
||||
}
|
||||
|
||||
labels, err := parseLabels()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -163,6 +177,13 @@ func createImageUpdateRun(cmd *cobra.Command, args []string) error {
|
||||
}
|
||||
}
|
||||
|
||||
if imageUpdateArgs.signingKeySecret != "" {
|
||||
update.Spec.GitSpec.Commit.SigningKey = &autov1.SigningKey{
|
||||
SecretRef: meta.LocalObjectReference{Name: imageUpdateArgs.signingKeySecret},
|
||||
Type: autov1.SigningKeyType(imageUpdateArgs.signingKeyType),
|
||||
}
|
||||
}
|
||||
|
||||
if createArgs.export {
|
||||
return printExport(exportImageUpdate(&update))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
Copyright 2026 The Flux authors
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCreateImageUpdate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args string
|
||||
assert assertFunc
|
||||
}{
|
||||
{
|
||||
name: "no signing key",
|
||||
args: "create image update flux-system --git-repo-ref=flux-system --checkout-branch=main --author-name=flux --author-email=flux@example.com --interval=1m0s --namespace=flux-system --export",
|
||||
assert: assertGoldenFile("./testdata/create_image_update/no-signing.yaml"),
|
||||
},
|
||||
{
|
||||
name: "signing secret without explicit type defaults to gpg",
|
||||
args: "create image update flux-system --git-repo-ref=flux-system --checkout-branch=main --author-name=flux --author-email=flux@example.com --signing-key-secret=my-key --interval=1m0s --namespace=flux-system --export",
|
||||
assert: assertGoldenFile("./testdata/create_image_update/signing-default-gpg.yaml"),
|
||||
},
|
||||
{
|
||||
name: "ssh signing key",
|
||||
args: "create image update flux-system --git-repo-ref=flux-system --checkout-branch=main --author-name=flux --author-email=flux@example.com --signing-key-secret=my-deploy-key --signing-key-type=ssh --interval=1m0s --namespace=flux-system --export",
|
||||
assert: assertGoldenFile("./testdata/create_image_update/signing-ssh.yaml"),
|
||||
},
|
||||
{
|
||||
name: "signing-key-type without secret errors",
|
||||
args: "create image update flux-system --git-repo-ref=flux-system --checkout-branch=main --author-name=flux --author-email=flux@example.com --signing-key-type=ssh --namespace=flux-system --export",
|
||||
assert: assertError("--signing-key-type requires --signing-key-secret"),
|
||||
},
|
||||
{
|
||||
name: "invalid signing-key-type errors",
|
||||
args: "create image update flux-system --git-repo-ref=flux-system --checkout-branch=main --author-name=flux --author-email=flux@example.com --signing-key-secret=k --signing-key-type=pgp --namespace=flux-system --export",
|
||||
assert: assertError("--signing-key-type must be one of: gpg, ssh"),
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cmd := cmdTestCase{
|
||||
args: tt.args,
|
||||
assert: tt.assert,
|
||||
}
|
||||
cmd.runTestCmd(t)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -136,6 +136,9 @@ func createKsCmdRun(cmd *cobra.Command, args []string) error {
|
||||
if !strings.HasPrefix(kustomizationArgs.path.String(), "./") {
|
||||
return fmt.Errorf("path must begin with ./")
|
||||
}
|
||||
if kustomizationArgs.source.Name == "" {
|
||||
return fmt.Errorf("source is required")
|
||||
}
|
||||
|
||||
if !createArgs.export {
|
||||
logger.Generatef("generating Kustomization")
|
||||
@@ -169,7 +172,7 @@ func createKsCmdRun(cmd *cobra.Command, args []string) error {
|
||||
}
|
||||
|
||||
if len(kustomizationArgs.dependsOn) > 0 {
|
||||
ls := utils.MakeDependsOn(kustomizationArgs.dependsOn)
|
||||
ls := meta.MakeDependsOn(kustomizationArgs.dependsOn)
|
||||
ksDependsOn := make([]kustomizev1.DependencyReference, 0, len(ls))
|
||||
for _, d := range ls {
|
||||
ksDependsOn = append(ksDependsOn, kustomizev1.DependencyReference{
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
//go:build unit
|
||||
// +build unit
|
||||
|
||||
/*
|
||||
Copyright 2026 The Flux authors
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCreateKustomization(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args string
|
||||
assert assertFunc
|
||||
}{
|
||||
{
|
||||
// A user creating a kustomization without --source gets a confusing
|
||||
// API-level error about spec.sourceRef.kind instead of a clear message.
|
||||
name: "missing source",
|
||||
args: "create kustomization my-app --path=./deploy --export",
|
||||
assert: assertError("source is required"),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cmd := cmdTestCase{
|
||||
args: tt.args,
|
||||
assert: tt.assert,
|
||||
}
|
||||
cmd.runTestCmd(t)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,7 @@ import (
|
||||
notificationv1 "github.com/fluxcd/notification-controller/api/v1"
|
||||
"github.com/fluxcd/pkg/apis/meta"
|
||||
|
||||
"github.com/fluxcd/flux2/v2/internal/flags"
|
||||
"github.com/fluxcd/flux2/v2/internal/utils"
|
||||
)
|
||||
|
||||
@@ -49,7 +50,7 @@ var createReceiverCmd = &cobra.Command{
|
||||
}
|
||||
|
||||
type receiverFlags struct {
|
||||
receiverType string
|
||||
receiverType flags.ReceiverType
|
||||
secretRef string
|
||||
events []string
|
||||
resources []string
|
||||
@@ -58,7 +59,7 @@ type receiverFlags struct {
|
||||
var receiverArgs receiverFlags
|
||||
|
||||
func init() {
|
||||
createReceiverCmd.Flags().StringVar(&receiverArgs.receiverType, "type", "", "")
|
||||
createReceiverCmd.Flags().Var(&receiverArgs.receiverType, "type", receiverArgs.receiverType.Description())
|
||||
createReceiverCmd.Flags().StringVar(&receiverArgs.secretRef, "secret-ref", "", "")
|
||||
createReceiverCmd.Flags().StringSliceVar(&receiverArgs.events, "event", []string{}, "also accepts comma-separated values")
|
||||
createReceiverCmd.Flags().StringSliceVar(&receiverArgs.resources, "resource", []string{}, "also accepts comma-separated values")
|
||||
@@ -76,16 +77,18 @@ func createReceiverCmdRun(cmd *cobra.Command, args []string) error {
|
||||
return fmt.Errorf("secret ref is required")
|
||||
}
|
||||
|
||||
resources := []notificationv1.CrossNamespaceObjectReference{}
|
||||
resources := []notificationv1.ReceiverResource{}
|
||||
for _, resource := range receiverArgs.resources {
|
||||
kind, name := utils.ParseObjectKindName(resource)
|
||||
if kind == "" {
|
||||
return fmt.Errorf("invalid event source '%s', must be in format <kind>/<name>", resource)
|
||||
}
|
||||
|
||||
resources = append(resources, notificationv1.CrossNamespaceObjectReference{
|
||||
Kind: kind,
|
||||
Name: name,
|
||||
resources = append(resources, notificationv1.ReceiverResource{
|
||||
CrossNamespaceObjectReference: notificationv1.CrossNamespaceObjectReference{
|
||||
Kind: kind,
|
||||
Name: name,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -109,10 +112,10 @@ func createReceiverCmdRun(cmd *cobra.Command, args []string) error {
|
||||
Labels: sourceLabels,
|
||||
},
|
||||
Spec: notificationv1.ReceiverSpec{
|
||||
Type: receiverArgs.receiverType,
|
||||
Type: receiverArgs.receiverType.String(),
|
||||
Events: receiverArgs.events,
|
||||
Resources: resources,
|
||||
SecretRef: meta.LocalObjectReference{
|
||||
SecretRef: &meta.LocalObjectReference{
|
||||
Name: receiverArgs.secretRef,
|
||||
},
|
||||
Suspend: false,
|
||||
|
||||
@@ -56,6 +56,22 @@ func upsertSecret(ctx context.Context, kubeClient client.Client, secret corev1.S
|
||||
}
|
||||
|
||||
existing.StringData = secret.StringData
|
||||
if secret.Annotations != nil {
|
||||
if existing.Annotations == nil {
|
||||
existing.Annotations = make(map[string]string)
|
||||
}
|
||||
for k, v := range secret.Annotations {
|
||||
existing.Annotations[k] = v
|
||||
}
|
||||
}
|
||||
if secret.Labels != nil {
|
||||
if existing.Labels == nil {
|
||||
existing.Labels = make(map[string]string)
|
||||
}
|
||||
for k, v := range secret.Labels {
|
||||
existing.Labels[k] = v
|
||||
}
|
||||
}
|
||||
if err := kubeClient.Update(ctx, &existing); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -46,16 +46,18 @@ var createSecretGitHubAppCmd = &cobra.Command{
|
||||
}
|
||||
|
||||
type secretGitHubAppFlags struct {
|
||||
appID string
|
||||
appInstallationID string
|
||||
privateKeyFile string
|
||||
baseURL string
|
||||
appID string
|
||||
appInstallationOwner string
|
||||
appInstallationID string
|
||||
privateKeyFile string
|
||||
baseURL string
|
||||
}
|
||||
|
||||
var secretGitHubAppArgs = secretGitHubAppFlags{}
|
||||
|
||||
func init() {
|
||||
createSecretGitHubAppCmd.Flags().StringVar(&secretGitHubAppArgs.appID, "app-id", "", "github app ID")
|
||||
createSecretGitHubAppCmd.Flags().StringVar(&secretGitHubAppArgs.appInstallationOwner, "app-installation-owner", "", "github app installation owner (user or organization)")
|
||||
createSecretGitHubAppCmd.Flags().StringVar(&secretGitHubAppArgs.appInstallationID, "app-installation-id", "", "github app installation ID")
|
||||
createSecretGitHubAppCmd.Flags().StringVar(&secretGitHubAppArgs.privateKeyFile, "app-private-key", "", "github app private key file path")
|
||||
createSecretGitHubAppCmd.Flags().StringVar(&secretGitHubAppArgs.baseURL, "app-base-url", "", "github app base URL")
|
||||
@@ -70,33 +72,19 @@ func createSecretGitHubAppCmdRun(cmd *cobra.Command, args []string) error {
|
||||
|
||||
secretName := args[0]
|
||||
|
||||
if secretGitHubAppArgs.appID == "" {
|
||||
return fmt.Errorf("--app-id is required")
|
||||
}
|
||||
|
||||
if secretGitHubAppArgs.appInstallationID == "" {
|
||||
return fmt.Errorf("--app-installation-id is required")
|
||||
}
|
||||
|
||||
if secretGitHubAppArgs.privateKeyFile == "" {
|
||||
return fmt.Errorf("--app-private-key is required")
|
||||
}
|
||||
|
||||
privateKey, err := os.ReadFile(secretGitHubAppArgs.privateKeyFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to read private key file: %w", err)
|
||||
}
|
||||
|
||||
opts := sourcesecret.Options{
|
||||
Name: secretName,
|
||||
Namespace: *kubeconfigArgs.Namespace,
|
||||
GitHubAppID: secretGitHubAppArgs.appID,
|
||||
GitHubAppInstallationID: secretGitHubAppArgs.appInstallationID,
|
||||
GitHubAppPrivateKey: string(privateKey),
|
||||
}
|
||||
|
||||
if secretGitHubAppArgs.baseURL != "" {
|
||||
opts.GitHubAppBaseURL = secretGitHubAppArgs.baseURL
|
||||
Name: secretName,
|
||||
Namespace: *kubeconfigArgs.Namespace,
|
||||
GitHubAppID: secretGitHubAppArgs.appID,
|
||||
GitHubAppInstallationOwner: secretGitHubAppArgs.appInstallationOwner,
|
||||
GitHubAppInstallationID: secretGitHubAppArgs.appInstallationID,
|
||||
GitHubAppPrivateKey: string(privateKey),
|
||||
GitHubAppBaseURL: secretGitHubAppArgs.baseURL,
|
||||
}
|
||||
|
||||
secret, err := sourcesecret.GenerateGitHubApp(opts)
|
||||
|
||||
@@ -31,21 +31,6 @@ func TestCreateSecretGitHubApp(t *testing.T) {
|
||||
args: "create secret githubapp",
|
||||
assert: assertError("name is required"),
|
||||
},
|
||||
{
|
||||
name: "create githubapp secret with missing app-id",
|
||||
args: "create secret githubapp appinfo",
|
||||
assert: assertError("--app-id is required"),
|
||||
},
|
||||
{
|
||||
name: "create githubapp secret with missing appInstallationID",
|
||||
args: "create secret githubapp appinfo --app-id 1",
|
||||
assert: assertError("--app-installation-id is required"),
|
||||
},
|
||||
{
|
||||
name: "create githubapp secret with missing private key file",
|
||||
args: "create secret githubapp appinfo --app-id 1 --app-installation-id 2",
|
||||
assert: assertError("--app-private-key is required"),
|
||||
},
|
||||
{
|
||||
name: "create githubapp secret with private key file that does not exist",
|
||||
args: "create secret githubapp appinfo --app-id 1 --app-installation-id 2 --app-private-key pk.pem",
|
||||
@@ -53,7 +38,7 @@ func TestCreateSecretGitHubApp(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "create githubapp secret with app info",
|
||||
args: "create secret githubapp appinfo --namespace my-namespace --app-id 1 --app-installation-id 2 --app-private-key ./testdata/create_secret/githubapp/test-private-key.pem --export",
|
||||
args: "create secret githubapp appinfo --namespace my-namespace --app-id 1 --app-installation-owner my-org --app-private-key ./testdata/create_secret/githubapp/test-private-key.pem --export",
|
||||
assert: assertGoldenFile("testdata/create_secret/githubapp/secret.yaml"),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
/*
|
||||
Copyright 2026 The Flux authors
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
"sigs.k8s.io/yaml"
|
||||
|
||||
notificationv1 "github.com/fluxcd/notification-controller/api/v1"
|
||||
|
||||
"github.com/fluxcd/flux2/v2/internal/flags"
|
||||
"github.com/fluxcd/flux2/v2/internal/utils"
|
||||
"github.com/fluxcd/flux2/v2/pkg/manifestgen/sourcesecret"
|
||||
)
|
||||
|
||||
var createSecretReceiverCmd = &cobra.Command{
|
||||
Use: "receiver [name]",
|
||||
Short: "Create or update a Kubernetes secret for a Receiver webhook",
|
||||
Long: `The create secret receiver command generates a Kubernetes secret with
|
||||
the token used for webhook payload validation and an annotation with the
|
||||
computed webhook URL.`,
|
||||
Example: ` # Create a receiver secret for a GitHub webhook
|
||||
flux create secret receiver github-receiver \
|
||||
--namespace=my-namespace \
|
||||
--type=github \
|
||||
--hostname=flux.example.com \
|
||||
--export
|
||||
|
||||
# Create a receiver secret for GCR with email claim
|
||||
flux create secret receiver gcr-receiver \
|
||||
--namespace=my-namespace \
|
||||
--type=gcr \
|
||||
--hostname=flux.example.com \
|
||||
--email-claim=sa@project.iam.gserviceaccount.com \
|
||||
--export`,
|
||||
RunE: createSecretReceiverCmdRun,
|
||||
}
|
||||
|
||||
type secretReceiverFlags struct {
|
||||
receiverType flags.ReceiverType
|
||||
token string
|
||||
hostname string
|
||||
emailClaim string
|
||||
audienceClaim string
|
||||
}
|
||||
|
||||
var secretReceiverArgs secretReceiverFlags
|
||||
|
||||
func init() {
|
||||
createSecretReceiverCmd.Flags().Var(&secretReceiverArgs.receiverType, "type", secretReceiverArgs.receiverType.Description())
|
||||
createSecretReceiverCmd.Flags().StringVar(&secretReceiverArgs.token, "token", "", "webhook token used for payload validation and URL computation, auto-generated if not specified")
|
||||
createSecretReceiverCmd.Flags().StringVar(&secretReceiverArgs.hostname, "hostname", "", "hostname for the webhook URL e.g. flux.example.com")
|
||||
createSecretReceiverCmd.Flags().StringVar(&secretReceiverArgs.emailClaim, "email-claim", "", "IAM service account email, required for gcr type")
|
||||
createSecretReceiverCmd.Flags().StringVar(&secretReceiverArgs.audienceClaim, "audience-claim", "", "custom OIDC token audience for gcr type, defaults to the webhook URL")
|
||||
|
||||
createSecretCmd.AddCommand(createSecretReceiverCmd)
|
||||
}
|
||||
|
||||
func createSecretReceiverCmdRun(cmd *cobra.Command, args []string) error {
|
||||
name := args[0]
|
||||
|
||||
if secretReceiverArgs.receiverType == "" {
|
||||
return fmt.Errorf("--type is required")
|
||||
}
|
||||
|
||||
if secretReceiverArgs.hostname == "" {
|
||||
return fmt.Errorf("--hostname is required")
|
||||
}
|
||||
|
||||
if secretReceiverArgs.receiverType.String() == notificationv1.GCRReceiver && secretReceiverArgs.emailClaim == "" {
|
||||
return fmt.Errorf("--email-claim is required for gcr receiver type")
|
||||
}
|
||||
|
||||
labels, err := parseLabels()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
opts := sourcesecret.Options{
|
||||
Name: name,
|
||||
Namespace: *kubeconfigArgs.Namespace,
|
||||
Labels: labels,
|
||||
ReceiverType: secretReceiverArgs.receiverType.String(),
|
||||
Token: secretReceiverArgs.token,
|
||||
Hostname: secretReceiverArgs.hostname,
|
||||
EmailClaim: secretReceiverArgs.emailClaim,
|
||||
AudienceClaim: secretReceiverArgs.audienceClaim,
|
||||
}
|
||||
|
||||
secret, err := sourcesecret.GenerateReceiver(opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if createArgs.export {
|
||||
rootCmd.Println(secret.Content)
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), rootArgs.timeout)
|
||||
defer cancel()
|
||||
kubeClient, err := utils.KubeClient(kubeconfigArgs, kubeclientOptions)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var s corev1.Secret
|
||||
if err := yaml.Unmarshal([]byte(secret.Content), &s); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := upsertSecret(ctx, kubeClient, s); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logger.Actionf("receiver secret '%s' created in '%s' namespace", name, *kubeconfigArgs.Namespace)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
Copyright 2026 The Flux authors
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCreateReceiverSecret(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args string
|
||||
assert assertFunc
|
||||
}{
|
||||
{
|
||||
name: "missing type",
|
||||
args: "create secret receiver test-secret --token=t --hostname=h",
|
||||
assert: assertError("--type is required"),
|
||||
},
|
||||
{
|
||||
name: "invalid type",
|
||||
args: "create secret receiver test-secret --type=invalid --token=t --hostname=h",
|
||||
assert: assertError("invalid argument \"invalid\" for \"--type\" flag: receiver type 'invalid' is not supported, must be one of: generic, generic-hmac, github, gitlab, bitbucket, harbor, dockerhub, quay, gcr, nexus, acr, cdevents"),
|
||||
},
|
||||
{
|
||||
name: "missing hostname",
|
||||
args: "create secret receiver test-secret --type=github --token=t",
|
||||
assert: assertError("--hostname is required"),
|
||||
},
|
||||
{
|
||||
name: "gcr missing email-claim",
|
||||
args: "create secret receiver test-secret --type=gcr --token=t --hostname=h",
|
||||
assert: assertError("--email-claim is required for gcr receiver type"),
|
||||
},
|
||||
{
|
||||
name: "github receiver secret",
|
||||
args: "create secret receiver receiver-secret --type=github --token=test-token --hostname=flux.example.com --namespace=my-namespace --export",
|
||||
assert: assertGoldenFile("testdata/create_secret/receiver/secret-receiver.yaml"),
|
||||
},
|
||||
{
|
||||
name: "gcr receiver secret",
|
||||
args: "create secret receiver gcr-secret --type=gcr --token=test-token --hostname=flux.example.com --email-claim=sa@project.iam.gserviceaccount.com --namespace=my-namespace --export",
|
||||
assert: assertGoldenFile("testdata/create_secret/receiver/secret-receiver-gcr.yaml"),
|
||||
},
|
||||
{
|
||||
name: "gcr receiver secret with custom audience",
|
||||
args: "create secret receiver gcr-secret --type=gcr --token=test-token --hostname=flux.example.com --email-claim=sa@project.iam.gserviceaccount.com --audience-claim=https://custom.audience.example.com --namespace=my-namespace --export",
|
||||
assert: assertGoldenFile("testdata/create_secret/receiver/secret-receiver-gcr-audience.yaml"),
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cmd := cmdTestCase{
|
||||
args: tt.args,
|
||||
assert: tt.assert,
|
||||
}
|
||||
cmd.runTestCmd(t)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -130,7 +130,7 @@ func createSourceChartCmdRun(cmd *cobra.Command, args []string) error {
|
||||
}
|
||||
|
||||
if provider := sourceChartArgs.verifyProvider.String(); provider != "" {
|
||||
helmChart.Spec.Verify = &sourcev1.OCIRepositoryVerification{
|
||||
helmChart.Spec.Verify = &sourcev1.HelmChartVerification{
|
||||
Provider: provider,
|
||||
}
|
||||
if secretName := sourceChartArgs.verifySecretRef; secretName != "" {
|
||||
|
||||
@@ -124,6 +124,12 @@ For private Git repositories, the basic authentication credentials are stored in
|
||||
--username=username \
|
||||
--password=password
|
||||
|
||||
# Create a source for a Git repository using AWS CodeCommit with IAM credentials
|
||||
flux create source git podinfo \
|
||||
--url=https://git-codecommit.<region>.amazonaws.com/v1/repos/podinfo \
|
||||
--branch=master \
|
||||
--provider=aws
|
||||
|
||||
# Create a source for a Git repository using azure provider
|
||||
flux create source git podinfo \
|
||||
--url=https://dev.azure.com/foo/bar/_git/podinfo \
|
||||
|
||||
@@ -152,7 +152,7 @@ func TestCreateSourceGitExport(t *testing.T) {
|
||||
{
|
||||
name: "source with invalid provider",
|
||||
args: "create source git podinfo --namespace=flux-system --url=https://dev.azure.com/foo/bar/_git/podinfo --provider dummy --branch=test --interval=1m0s --export",
|
||||
assert: assertError("invalid argument \"dummy\" for \"--provider\" flag: source Git provider 'dummy' is not supported, must be one of: generic|azure|github"),
|
||||
assert: assertError("invalid argument \"dummy\" for \"--provider\" flag: source Git provider 'dummy' is not supported, must be one of: generic|github|aws|azure"),
|
||||
},
|
||||
{
|
||||
name: "source with empty provider",
|
||||
|
||||
@@ -114,9 +114,16 @@ func createSourceHelmCmdRun(cmd *cobra.Command, args []string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := url.Parse(sourceHelmArgs.url); err != nil {
|
||||
helmURL, err := url.Parse(sourceHelmArgs.url)
|
||||
if err != nil {
|
||||
return fmt.Errorf("url parse failed: %w", err)
|
||||
}
|
||||
if helmURL.Scheme != "http" && helmURL.Scheme != "https" && helmURL.Scheme != sourcev1.HelmRepositoryTypeOCI {
|
||||
return fmt.Errorf("url scheme '%s' not supported, can be: http, https and oci", helmURL.Scheme)
|
||||
}
|
||||
if helmURL.Host == "" {
|
||||
return fmt.Errorf("url host is required")
|
||||
}
|
||||
|
||||
helmRepository := &sourcev1.HelmRepository{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
@@ -132,11 +139,7 @@ func createSourceHelmCmdRun(cmd *cobra.Command, args []string) error {
|
||||
},
|
||||
}
|
||||
|
||||
url, err := url.Parse(sourceHelmArgs.url)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse URL: %w", err)
|
||||
}
|
||||
if url.Scheme == sourcev1.HelmRepositoryTypeOCI {
|
||||
if helmURL.Scheme == sourcev1.HelmRepositoryTypeOCI {
|
||||
helmRepository.Spec.Type = sourcev1.HelmRepositoryTypeOCI
|
||||
helmRepository.Spec.Provider = sourceHelmArgs.ociProvider
|
||||
}
|
||||
|
||||
@@ -36,6 +36,12 @@ func TestCreateSourceHelm(t *testing.T) {
|
||||
resultFile: "name is required",
|
||||
assertFunc: "assertError",
|
||||
},
|
||||
{
|
||||
name: "unsupported URL scheme",
|
||||
args: "create source helm podinfo --url=git://example.com/charts --export",
|
||||
resultFile: "url scheme 'git' not supported, can be: http, https and oci",
|
||||
assertFunc: "assertError",
|
||||
},
|
||||
{
|
||||
name: "OCI repo",
|
||||
args: "create source helm podinfo --url=oci://ghcr.io/stefanprodan/charts/podinfo --interval 5m --export",
|
||||
|
||||
@@ -53,6 +53,12 @@ var createSourceOCIRepositoryCmd = &cobra.Command{
|
||||
--verify-provider=cosign \
|
||||
--verify-subject="^https://github.com/stefanprodan/podinfo/.github/workflows/release.yml@refs/tags/6.6.2$" \
|
||||
--verify-issuer="^https://token.actions.githubusercontent.com$"
|
||||
|
||||
# Create an OCIRepository for a Helm chart layer
|
||||
flux create source oci valkey-cluster \
|
||||
--url=oci://example.com/charts/valkey \
|
||||
--tag=0.11.6 \
|
||||
--layer-selector=application/vnd.cncf.helm.chart.content.v1.tar+gzip:copy
|
||||
`,
|
||||
RunE: createSourceOCIRepositoryCmdRun,
|
||||
}
|
||||
@@ -73,6 +79,7 @@ type sourceOCIRepositoryFlags struct {
|
||||
ignorePaths []string
|
||||
provider flags.SourceOCIProvider
|
||||
insecure bool
|
||||
layerSelector string
|
||||
}
|
||||
|
||||
var sourceOCIRepositoryArgs = newSourceOCIFlags()
|
||||
@@ -99,6 +106,7 @@ func init() {
|
||||
createSourceOCIRepositoryCmd.Flags().StringVar(&sourceOCIRepositoryArgs.verifyOIDCIssuer, "verify-issuer", "", "regular expression to use for the OIDC issuer during signature verification")
|
||||
createSourceOCIRepositoryCmd.Flags().StringSliceVar(&sourceOCIRepositoryArgs.ignorePaths, "ignore-paths", nil, "set paths to ignore resources (can specify multiple paths with commas: path1,path2)")
|
||||
createSourceOCIRepositoryCmd.Flags().BoolVar(&sourceOCIRepositoryArgs.insecure, "insecure", false, "for when connecting to a non-TLS registries over plain HTTP")
|
||||
createSourceOCIRepositoryCmd.Flags().StringVar(&sourceOCIRepositoryArgs.layerSelector, "layer-selector", "", "the OCI artifact layer selector in the format '<media-type>:<operation>'")
|
||||
|
||||
createSourceCmd.AddCommand(createSourceOCIRepositoryCmd)
|
||||
}
|
||||
@@ -114,6 +122,11 @@ func createSourceOCIRepositoryCmdRun(cmd *cobra.Command, args []string) error {
|
||||
return fmt.Errorf("--tag, --tag-semver or --digest is required")
|
||||
}
|
||||
|
||||
layerSelector, err := parseLayerSelector(sourceOCIRepositoryArgs.layerSelector)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sourceLabels, err := parseLabels()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -152,6 +165,7 @@ func createSourceOCIRepositoryCmdRun(cmd *cobra.Command, args []string) error {
|
||||
if tag := sourceOCIRepositoryArgs.tag; tag != "" {
|
||||
repository.Spec.Reference.Tag = tag
|
||||
}
|
||||
repository.Spec.LayerSelector = layerSelector
|
||||
|
||||
if createSourceArgs.fetchTimeout > 0 {
|
||||
repository.Spec.Timeout = &metav1.Duration{Duration: createSourceArgs.fetchTimeout}
|
||||
@@ -234,6 +248,28 @@ func createSourceOCIRepositoryCmdRun(cmd *cobra.Command, args []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseLayerSelector(selector string) (*sourcev1.OCILayerSelector, error) {
|
||||
if selector == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
mediaType, operation, found := strings.Cut(selector, ":")
|
||||
if !found || mediaType == "" || operation == "" {
|
||||
return nil, fmt.Errorf("invalid --layer-selector %q: must be in the format '<media-type>:<operation>'", selector)
|
||||
}
|
||||
|
||||
switch operation {
|
||||
case sourcev1.OCILayerExtract, sourcev1.OCILayerCopy:
|
||||
default:
|
||||
return nil, fmt.Errorf("invalid --layer-selector %q: operation must be %q or %q", selector, sourcev1.OCILayerExtract, sourcev1.OCILayerCopy)
|
||||
}
|
||||
|
||||
return &sourcev1.OCILayerSelector{
|
||||
MediaType: mediaType,
|
||||
Operation: operation,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func upsertOCIRepository(ctx context.Context, kubeClient client.Client,
|
||||
ociRepository *sourcev1.OCIRepository) (types.NamespacedName, error) {
|
||||
namespacedName := types.NamespacedName{
|
||||
|
||||
@@ -81,6 +81,21 @@ func TestCreateSourceOCI(t *testing.T) {
|
||||
args: "create source oci podinfo --url=oci://ghcr.io/stefanprodan/manifests/podinfo --tag=6.3.5 --interval 10m --verify-provider=cosign --verify-secret-ref=cosign-pub --export",
|
||||
assertFunc: assertGoldenFile("./testdata/oci/export_with_verify_secret.golden"),
|
||||
},
|
||||
{
|
||||
name: "export manifest with layer selector",
|
||||
args: "create source oci podinfo --url=oci://ghcr.io/stefanprodan/manifests/podinfo --tag=6.3.5 --interval 10m --layer-selector=application/vnd.cncf.helm.chart.content.v1.tar+gzip:copy --export",
|
||||
assertFunc: assertGoldenFile("./testdata/oci/export_with_layer_selector.golden"),
|
||||
},
|
||||
{
|
||||
name: "invalid layer selector operation",
|
||||
args: "create source oci podinfo --url=oci://ghcr.io/stefanprodan/manifests/podinfo --tag=6.3.5 --layer-selector=application/vnd.cncf.helm.chart.content.v1.tar+gzip:move --export",
|
||||
assertFunc: assertError("invalid --layer-selector \"application/vnd.cncf.helm.chart.content.v1.tar+gzip:move\": operation must be \"extract\" or \"copy\""),
|
||||
},
|
||||
{
|
||||
name: "invalid layer selector format",
|
||||
args: "create source oci podinfo --url=oci://ghcr.io/stefanprodan/manifests/podinfo --tag=6.3.5 --layer-selector=application/vnd.cncf.helm.chart.content.v1.tar+gzip --export",
|
||||
assertFunc: assertError("invalid --layer-selector \"application/vnd.cncf.helm.chart.content.v1.tar+gzip\": must be in the format '<media-type>:<operation>'"),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
+28
-19
@@ -58,9 +58,10 @@ const (
|
||||
)
|
||||
|
||||
type tenantFlags struct {
|
||||
namespaces []string
|
||||
clusterRole string
|
||||
account string
|
||||
namespaces []string
|
||||
clusterRole string
|
||||
account string
|
||||
skipNamespace bool
|
||||
}
|
||||
|
||||
var tenantArgs tenantFlags
|
||||
@@ -69,6 +70,7 @@ func init() {
|
||||
createTenantCmd.Flags().StringSliceVar(&tenantArgs.namespaces, "with-namespace", nil, "namespace belonging to this tenant")
|
||||
createTenantCmd.Flags().StringVar(&tenantArgs.clusterRole, "cluster-role", "cluster-admin", "cluster role of the tenant role binding")
|
||||
createTenantCmd.Flags().StringVar(&tenantArgs.account, "with-service-account", "", "service account belonging to this tenant")
|
||||
createTenantCmd.Flags().BoolVar(&tenantArgs.skipNamespace, "skip-namespace", false, "skip namespace creation (namespace must exist already)")
|
||||
createCmd.AddCommand(createTenantCmd)
|
||||
}
|
||||
|
||||
@@ -157,7 +159,7 @@ func createTenantCmdRun(cmd *cobra.Command, args []string) error {
|
||||
|
||||
if createArgs.export {
|
||||
for i := range tenantArgs.namespaces {
|
||||
if err := exportTenant(namespaces[i], accounts[i], roleBindings[i]); err != nil {
|
||||
if err := exportTenant(namespaces[i], accounts[i], roleBindings[i], tenantArgs.skipNamespace); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -173,9 +175,11 @@ func createTenantCmdRun(cmd *cobra.Command, args []string) error {
|
||||
}
|
||||
|
||||
for i := range tenantArgs.namespaces {
|
||||
logger.Actionf("applying namespace %s", namespaces[i].Name)
|
||||
if err := upsertNamespace(ctx, kubeClient, namespaces[i]); err != nil {
|
||||
return err
|
||||
if !tenantArgs.skipNamespace {
|
||||
logger.Actionf("applying namespace %s", namespaces[i].Name)
|
||||
if err := upsertNamespace(ctx, kubeClient, namespaces[i]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
logger.Actionf("applying service account %s", accounts[i].Name)
|
||||
@@ -284,19 +288,24 @@ func upsertRoleBinding(ctx context.Context, kubeClient client.Client, roleBindin
|
||||
return nil
|
||||
}
|
||||
|
||||
func exportTenant(namespace corev1.Namespace, account corev1.ServiceAccount, roleBinding rbacv1.RoleBinding) error {
|
||||
namespace.TypeMeta = metav1.TypeMeta{
|
||||
APIVersion: "v1",
|
||||
Kind: "Namespace",
|
||||
}
|
||||
data, err := yaml.Marshal(namespace)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data = bytes.Replace(data, []byte("spec: {}\n"), []byte(""), 1)
|
||||
func exportTenant(namespace corev1.Namespace, account corev1.ServiceAccount, roleBinding rbacv1.RoleBinding, skipNamespace bool) error {
|
||||
var data []byte
|
||||
var err error
|
||||
|
||||
printlnStdout("---")
|
||||
printlnStdout(resourceToString(data))
|
||||
if !skipNamespace {
|
||||
namespace.TypeMeta = metav1.TypeMeta{
|
||||
APIVersion: "v1",
|
||||
Kind: "Namespace",
|
||||
}
|
||||
data, err = yaml.Marshal(namespace)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data = bytes.Replace(data, []byte("spec: {}\n"), []byte(""), 1)
|
||||
|
||||
printlnStdout("---")
|
||||
printlnStdout(resourceToString(data))
|
||||
}
|
||||
|
||||
account.TypeMeta = metav1.TypeMeta{
|
||||
APIVersion: "v1",
|
||||
|
||||
@@ -54,6 +54,11 @@ func TestCreateTenant(t *testing.T) {
|
||||
args: "create tenant dev-team --with-namespace=apps --cluster-role=custom-role --export",
|
||||
assert: assertGoldenFile("./testdata/create_tenant/tenant-with-cluster-role.yaml"),
|
||||
},
|
||||
{
|
||||
name: "tenant with skip namespace",
|
||||
args: "create tenant dev-team --with-namespace=apps --cluster-role=cluster-admin --skip-namespace --export",
|
||||
assert: assertGoldenFile("./testdata/create_tenant/tenant-with-skip-namespace.yaml"),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
@@ -93,7 +93,7 @@ func diffArtifactCmdRun(cmd *cobra.Command, args []string) error {
|
||||
|
||||
if diffArtifactArgs.provider.String() != sourcev1.GenericOCIProvider {
|
||||
logger.Actionf("logging in to registry with provider credentials")
|
||||
opt, err := loginWithProvider(ctx, url, diffArtifactArgs.provider.String())
|
||||
opt, _, err := loginWithProvider(ctx, url, diffArtifactArgs.provider.String())
|
||||
if err != nil {
|
||||
return fmt.Errorf("error during login with provider: %w", err)
|
||||
}
|
||||
|
||||
@@ -62,6 +62,8 @@ type diffKsFlags struct {
|
||||
strictSubst bool
|
||||
recursive bool
|
||||
localSources map[string]string
|
||||
inMemoryBuild bool
|
||||
ignoreNotFound bool
|
||||
}
|
||||
|
||||
var diffKsArgs diffKsFlags
|
||||
@@ -75,6 +77,10 @@ func init() {
|
||||
"When enabled, the post build substitutions will fail if a var without a default value is declared in files but is missing from the input vars.")
|
||||
diffKsCmd.Flags().BoolVarP(&diffKsArgs.recursive, "recursive", "r", false, "Recursively diff Kustomizations")
|
||||
diffKsCmd.Flags().StringToStringVar(&diffKsArgs.localSources, "local-sources", nil, "Comma-separated list of repositories in format: Kind/namespace/name=path")
|
||||
diffKsCmd.Flags().BoolVar(&diffKsArgs.inMemoryBuild, "in-memory-build", true,
|
||||
"Use in-memory filesystem during build.")
|
||||
diffKsCmd.Flags().BoolVar(&diffKsArgs.ignoreNotFound, "ignore-not-found", false,
|
||||
"Ignore Kustomization not found errors on the cluster when diffing.")
|
||||
diffCmd.AddCommand(diffKsCmd)
|
||||
}
|
||||
|
||||
@@ -113,6 +119,8 @@ func diffKsCmdRun(cmd *cobra.Command, args []string) error {
|
||||
build.WithRecursive(diffKsArgs.recursive),
|
||||
build.WithLocalSources(diffKsArgs.localSources),
|
||||
build.WithSingleKustomization(),
|
||||
build.WithInMemoryBuild(diffKsArgs.inMemoryBuild),
|
||||
build.WithIgnoreNotFound(diffKsArgs.ignoreNotFound),
|
||||
)
|
||||
} else {
|
||||
builder, err = build.NewBuilder(name, diffKsArgs.path,
|
||||
@@ -124,6 +132,8 @@ func diffKsCmdRun(cmd *cobra.Command, args []string) error {
|
||||
build.WithRecursive(diffKsArgs.recursive),
|
||||
build.WithLocalSources(diffKsArgs.localSources),
|
||||
build.WithSingleKustomization(),
|
||||
build.WithInMemoryBuild(diffKsArgs.inMemoryBuild),
|
||||
build.WithIgnoreNotFound(diffKsArgs.ignoreNotFound),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ func TestDiffKustomization(t *testing.T) {
|
||||
name: "diff nothing deployed",
|
||||
args: "diff kustomization podinfo --path ./testdata/build-kustomization/podinfo --progress-bar=false",
|
||||
objectFile: "",
|
||||
assert: assertGoldenFile("./testdata/diff-kustomization/nothing-is-deployed.golden"),
|
||||
assert: assertGoldenFile("./testdata/diff-kustomization/diff-new-kustomization.golden"),
|
||||
},
|
||||
{
|
||||
name: "diff with a deployment object",
|
||||
@@ -96,7 +96,7 @@ func TestDiffKustomization(t *testing.T) {
|
||||
name: "diff where kustomization file has multiple objects with the same name",
|
||||
args: "diff kustomization podinfo --path ./testdata/build-kustomization/podinfo --progress-bar=false --kustomization-file ./testdata/diff-kustomization/flux-kustomization-multiobj.yaml",
|
||||
objectFile: "",
|
||||
assert: assertGoldenFile("./testdata/diff-kustomization/nothing-is-deployed.golden"),
|
||||
assert: assertGoldenFile("./testdata/diff-kustomization/diff-new-kustomization.golden"),
|
||||
},
|
||||
{
|
||||
name: "diff with recursive",
|
||||
@@ -138,6 +138,118 @@ func TestDiffKustomization(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestDiffKustomizationNotDeployed tests `flux diff ks` when the Kustomization
|
||||
// CR does not exist in the cluster but is provided via --kustomization-file.
|
||||
// Reproduces https://github.com/fluxcd/flux2/issues/5439
|
||||
func TestDiffKustomizationNotDeployed(t *testing.T) {
|
||||
// Use a dedicated namespace with NO setup() -- the Kustomization CR
|
||||
// intentionally does not exist in the cluster.
|
||||
tmpl := map[string]string{
|
||||
"fluxns": allocateNamespace("flux-system"),
|
||||
}
|
||||
setupTestNamespace(tmpl["fluxns"], t)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
args string
|
||||
assert assertFunc
|
||||
}{
|
||||
{
|
||||
name: "fails without --ignore-not-found",
|
||||
args: "diff kustomization podinfo --path ./testdata/build-kustomization/podinfo --progress-bar=false " +
|
||||
"--kustomization-file ./testdata/diff-kustomization/flux-kustomization-local-only.yaml",
|
||||
assert: assertError("failed to get kustomization object: kustomizations.kustomize.toolkit.fluxcd.io \"podinfo\" not found"),
|
||||
},
|
||||
{
|
||||
name: "succeeds with --ignore-not-found and --kustomization-file",
|
||||
args: "diff kustomization podinfo --path ./testdata/build-kustomization/podinfo --progress-bar=false " +
|
||||
"--kustomization-file ./testdata/diff-kustomization/flux-kustomization-local-only.yaml " +
|
||||
"--ignore-not-found",
|
||||
assert: assertGoldenFile("./testdata/diff-kustomization/diff-new-kustomization.golden"),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cmd := cmdTestCase{
|
||||
args: tt.args + " -n " + tmpl["fluxns"],
|
||||
assert: tt.assert,
|
||||
}
|
||||
cmd.runTestCmd(t)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDiffKustomizationTakeOwnership tests `flux diff ks` when taking ownership
|
||||
// of existing resources on the cluster. A "pre-existing" configmap is applied
|
||||
// to the cluster, and the kustomization contains a matching configmap; the
|
||||
// diff should show the labels added by flux
|
||||
func TestDiffKustomizationTakeOwnership(t *testing.T) {
|
||||
tmpl := map[string]string{
|
||||
"fluxns": allocateNamespace("flux-system"),
|
||||
}
|
||||
setupTestNamespace(tmpl["fluxns"], t)
|
||||
|
||||
b, _ := build.NewBuilder("configmaps", "", build.WithClientConfig(kubeconfigArgs, kubeclientOptions))
|
||||
resourceManager, err := b.Manager()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Pre-create the "existing" configmap in the cluster without Flux labels
|
||||
if _, err := resourceManager.ApplyAll(context.Background(), createObjectFromFile("./testdata/diff-kustomization/existing-configmap.yaml", tmpl, t), ssa.DefaultApplyOptions()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cmd := cmdTestCase{
|
||||
args: "diff kustomization configmaps --path ./testdata/build-kustomization/configmaps --progress-bar=false " +
|
||||
"--kustomization-file ./testdata/diff-kustomization/flux-kustomization-configmaps.yaml " +
|
||||
"--ignore-not-found" +
|
||||
" -n " + tmpl["fluxns"],
|
||||
assert: assertGoldenFile("./testdata/diff-kustomization/diff-taking-ownership.golden"),
|
||||
}
|
||||
cmd.runTestCmd(t)
|
||||
}
|
||||
|
||||
// TestDiffKustomizationNewNamespaceAndConfigmap runs `flux diff ks` when the
|
||||
// kustomization creates a new namespace and resources inside it. The server-side
|
||||
// dry-run cannot resolve resources in a namespace that doesn't exist yet,
|
||||
// consistent with `kubectl diff --server-side` behavior.
|
||||
func TestDiffKustomizationNewNamespaceAndConfigmap(t *testing.T) {
|
||||
tmpl := map[string]string{
|
||||
"fluxns": allocateNamespace("flux-system"),
|
||||
}
|
||||
setupTestNamespace(tmpl["fluxns"], t)
|
||||
|
||||
cmd := cmdTestCase{
|
||||
args: "diff kustomization new-namespace-and-configmap --path ./testdata/build-kustomization/new-namespace-and-configmap --progress-bar=false " +
|
||||
"--kustomization-file ./testdata/diff-kustomization/flux-kustomization-new-namespace-and-configmap.yaml " +
|
||||
"--ignore-not-found" +
|
||||
" -n " + tmpl["fluxns"],
|
||||
assert: assertError("ConfigMap/new-ns/app-config not found: namespaces \"new-ns\" not found"),
|
||||
}
|
||||
cmd.runTestCmd(t)
|
||||
}
|
||||
|
||||
// TestDiffKustomizationNewNamespaceOnly runs `flux diff ks` when the
|
||||
// kustomization creates only a new namespace. The diff should show the
|
||||
// namespace as created.
|
||||
func TestDiffKustomizationNewNamespaceOnly(t *testing.T) {
|
||||
tmpl := map[string]string{
|
||||
"fluxns": allocateNamespace("flux-system"),
|
||||
}
|
||||
setupTestNamespace(tmpl["fluxns"], t)
|
||||
|
||||
cmd := cmdTestCase{
|
||||
args: "diff kustomization new-namespace-only --path ./testdata/build-kustomization/new-namespace-only --progress-bar=false " +
|
||||
"--kustomization-file ./testdata/diff-kustomization/flux-kustomization-new-namespace-only.yaml " +
|
||||
"--ignore-not-found" +
|
||||
" -n " + tmpl["fluxns"],
|
||||
assert: assertGoldenFile("./testdata/diff-kustomization/diff-new-namespace-only.golden"),
|
||||
}
|
||||
cmd.runTestCmd(t)
|
||||
}
|
||||
|
||||
func createObjectFromFile(objectFile string, templateValues map[string]string, t *testing.T) []*unstructured.Unstructured {
|
||||
buf, err := os.ReadFile(objectFile)
|
||||
if err != nil {
|
||||
@@ -158,3 +270,36 @@ func createObjectFromFile(objectFile string, templateValues map[string]string, t
|
||||
|
||||
return clientObjects
|
||||
}
|
||||
|
||||
// TestDiffKustomizationDriftIgnoreRules tests `flux diff ks` with drift ignore
|
||||
// rules. A service with a drifted port is pre-applied to the cluster, and the
|
||||
// kustomization specifies driftIgnoreRules that ignore /spec/ports on Services.
|
||||
// The diff should not show the service as drifted.
|
||||
func TestDiffKustomizationDriftIgnoreRules(t *testing.T) {
|
||||
tmpl := map[string]string{
|
||||
"fluxns": allocateNamespace("flux-system"),
|
||||
}
|
||||
setupTestNamespace(tmpl["fluxns"], t)
|
||||
|
||||
b, _ := build.NewBuilder("podinfo", "", build.WithClientConfig(kubeconfigArgs, kubeclientOptions))
|
||||
resourceManager, err := b.Manager()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Pre-apply the drifted service (port 9899 instead of 9898) without Flux labels.
|
||||
if _, err := resourceManager.ApplyAll(context.Background(), createObjectFromFile("./testdata/diff-kustomization/drifted-service-no-labels.yaml", tmpl, t), ssa.DefaultApplyOptions()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cmd := cmdTestCase{
|
||||
args: "diff kustomization podinfo --path ./testdata/build-kustomization/podinfo --progress-bar=false " +
|
||||
"--kustomization-file ./testdata/diff-kustomization/flux-kustomization-drift-ignore.yaml " +
|
||||
"--ignore-not-found" +
|
||||
" -n " + tmpl["fluxns"],
|
||||
assert: assertGoldenFile("./testdata/diff-kustomization/diff-with-drift-ignore.golden"),
|
||||
}
|
||||
cmd.runTestCmd(t)
|
||||
|
||||
testEnv.DeleteObjectFile("./testdata/diff-kustomization/drifted-service-no-labels.yaml", tmpl, t)
|
||||
}
|
||||
|
||||
+5
-2
@@ -196,11 +196,14 @@ func getRows(ctx context.Context, kubeclient client.Client, clientListOpts []cli
|
||||
|
||||
func addEventsToList(ctx context.Context, kubeclient client.Client, el *corev1.EventList, clientListOpts []client.ListOption) error {
|
||||
listOpts := &metav1.ListOptions{}
|
||||
clientListOpts = append(clientListOpts, client.Limit(cmdutil.DefaultChunkSize))
|
||||
err := runtimeresource.FollowContinue(listOpts,
|
||||
func(options metav1.ListOptions) (runtime.Object, error) {
|
||||
newEvents := &corev1.EventList{}
|
||||
if err := kubeclient.List(ctx, newEvents, clientListOpts...); err != nil {
|
||||
opts := append(clientListOpts, client.Limit(cmdutil.DefaultChunkSize))
|
||||
if options.Continue != "" {
|
||||
opts = append(opts, client.Continue(options.Continue))
|
||||
}
|
||||
if err := kubeclient.List(ctx, newEvents, opts...); err != nil {
|
||||
return nil, fmt.Errorf("error getting events: %w", err)
|
||||
}
|
||||
el.Items = append(el.Items, newEvents.Items...)
|
||||
|
||||
@@ -20,11 +20,13 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
. "github.com/onsi/gomega"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
"k8s.io/apimachinery/pkg/api/meta"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/fields"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
@@ -419,6 +421,108 @@ func createEvent(obj client.Object, eventType, msg, reason string) corev1.Event
|
||||
}
|
||||
}
|
||||
|
||||
// paginatedClient wraps a client.Client and simulates real Kubernetes API
|
||||
// pagination by splitting List results into pages of pageSize items,
|
||||
// using the ListMeta.Continue token.
|
||||
type paginatedClient struct {
|
||||
client.Client
|
||||
pageSize int
|
||||
}
|
||||
|
||||
func (c *paginatedClient) List(ctx context.Context, list client.ObjectList, opts ...client.ListOption) error {
|
||||
listOpts := &client.ListOptions{}
|
||||
listOpts.ApplyOptions(opts)
|
||||
|
||||
// Fetch all results from the underlying client (without Limit/Continue).
|
||||
stripped := make([]client.ListOption, 0, len(opts))
|
||||
for _, o := range opts {
|
||||
if _, ok := o.(client.Limit); ok {
|
||||
continue
|
||||
}
|
||||
if _, ok := o.(client.Continue); ok {
|
||||
continue
|
||||
}
|
||||
stripped = append(stripped, o)
|
||||
}
|
||||
if err := c.Client.List(ctx, list, stripped...); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
items, err := meta.ExtractList(list)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Determine the page window based on the Continue token.
|
||||
start := 0
|
||||
if listOpts.Continue != "" {
|
||||
n, err := strconv.Atoi(listOpts.Continue)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid continue token: %w", err)
|
||||
}
|
||||
start = n
|
||||
}
|
||||
if start > len(items) {
|
||||
start = len(items)
|
||||
}
|
||||
|
||||
end := start + c.pageSize
|
||||
if end > len(items) {
|
||||
end = len(items)
|
||||
}
|
||||
|
||||
page := items[start:end]
|
||||
if err := meta.SetList(list, page); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Set the Continue token when there are more pages.
|
||||
listAccessor, err := meta.ListAccessor(list)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if end < len(items) {
|
||||
listAccessor.SetContinue(strconv.Itoa(end))
|
||||
} else {
|
||||
listAccessor.SetContinue("")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func Test_addEventsToList_pagination(t *testing.T) {
|
||||
g := NewWithT(t)
|
||||
objs, err := ssautil.ReadObjects(strings.NewReader(objects))
|
||||
g.Expect(err).To(Not(HaveOccurred()))
|
||||
|
||||
builder := fake.NewClientBuilder().WithScheme(utils.NewScheme())
|
||||
for _, obj := range objs {
|
||||
builder = builder.WithObjects(obj)
|
||||
}
|
||||
|
||||
eventList := &corev1.EventList{}
|
||||
for _, obj := range objs {
|
||||
infoEvent := createEvent(obj, eventv1.EventSeverityInfo, "Info Message", "Info Reason")
|
||||
warningEvent := createEvent(obj, eventv1.EventSeverityError, "Error Message", "Error Reason")
|
||||
eventList.Items = append(eventList.Items, infoEvent, warningEvent)
|
||||
}
|
||||
builder = builder.WithLists(eventList)
|
||||
c := builder.Build()
|
||||
|
||||
totalEvents := len(eventList.Items)
|
||||
g.Expect(totalEvents).To(BeNumerically(">", 2), "need more than 2 events to test pagination")
|
||||
|
||||
// Wrap the client to paginate at 2 items per page, forcing multiple
|
||||
// round-trips through FollowContinue.
|
||||
pc := &paginatedClient{Client: c, pageSize: 2}
|
||||
|
||||
el := &corev1.EventList{}
|
||||
err = addEventsToList(context.Background(), pc, el, nil)
|
||||
g.Expect(err).To(Not(HaveOccurred()))
|
||||
g.Expect(el.Items).To(HaveLen(totalEvents),
|
||||
"addEventsToList should collect all events across paginated responses")
|
||||
}
|
||||
|
||||
func kindNameIndexer(obj client.Object) []string {
|
||||
e, ok := obj.(*corev1.Event)
|
||||
if !ok {
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
/*
|
||||
Copyright 2025 The Flux authors
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
|
||||
sourcev1 "github.com/fluxcd/source-controller/api/v1"
|
||||
)
|
||||
|
||||
var exportSourceExternalCmd = &cobra.Command{
|
||||
Use: "external [name]",
|
||||
Short: "Export ExternalArtifact sources in YAML format",
|
||||
Long: "The export source external command exports one or all ExternalArtifact sources in YAML format.",
|
||||
Example: ` # Export all ExternalArtifact sources
|
||||
flux export source external --all > sources.yaml
|
||||
|
||||
# Export a specific ExternalArtifact
|
||||
flux export source external my-artifact > source.yaml`,
|
||||
ValidArgsFunction: resourceNamesCompletionFunc(sourcev1.GroupVersion.WithKind(sourcev1.ExternalArtifactKind)),
|
||||
RunE: exportWithSecretCommand{
|
||||
list: externalArtifactListAdapter{&sourcev1.ExternalArtifactList{}},
|
||||
object: externalArtifactAdapter{&sourcev1.ExternalArtifact{}},
|
||||
}.run,
|
||||
}
|
||||
|
||||
func init() {
|
||||
exportSourceCmd.AddCommand(exportSourceExternalCmd)
|
||||
}
|
||||
|
||||
func exportExternalArtifact(source *sourcev1.ExternalArtifact) any {
|
||||
gvk := sourcev1.GroupVersion.WithKind(sourcev1.ExternalArtifactKind)
|
||||
export := sourcev1.ExternalArtifact{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
Kind: gvk.Kind,
|
||||
APIVersion: gvk.GroupVersion().String(),
|
||||
},
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: source.Name,
|
||||
Namespace: source.Namespace,
|
||||
Labels: source.Labels,
|
||||
Annotations: source.Annotations,
|
||||
},
|
||||
Spec: source.Spec,
|
||||
}
|
||||
return export
|
||||
}
|
||||
|
||||
func getExternalArtifactSecret(source *sourcev1.ExternalArtifact) *types.NamespacedName {
|
||||
// ExternalArtifact does not have a secretRef in its spec, this satisfies the interface
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ex externalArtifactAdapter) secret() *types.NamespacedName {
|
||||
return getExternalArtifactSecret(ex.ExternalArtifact)
|
||||
}
|
||||
|
||||
func (ex externalArtifactListAdapter) secretItem(i int) *types.NamespacedName {
|
||||
return getExternalArtifactSecret(&ex.ExternalArtifactList.Items[i])
|
||||
}
|
||||
|
||||
func (ex externalArtifactAdapter) export() any {
|
||||
return exportExternalArtifact(ex.ExternalArtifact)
|
||||
}
|
||||
|
||||
func (ex externalArtifactListAdapter) exportItem(i int) any {
|
||||
return exportExternalArtifact(&ex.ExternalArtifactList.Items[i])
|
||||
}
|
||||
@@ -110,6 +110,12 @@ func TestExport(t *testing.T) {
|
||||
"testdata/export/bucket.yaml",
|
||||
tmpl,
|
||||
},
|
||||
{
|
||||
"source external",
|
||||
"export source external flux-system",
|
||||
"testdata/export/external-artifact.yaml",
|
||||
tmpl,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range cases {
|
||||
|
||||
+25
-6
@@ -78,7 +78,7 @@ func init() {
|
||||
getCmd.PersistentFlags().BoolVarP(&getArgs.noHeader, "no-header", "", false, "skip the header when printing the results")
|
||||
getCmd.PersistentFlags().BoolVarP(&getArgs.watch, "watch", "w", false, "After listing/getting the requested object, watch for changes.")
|
||||
getCmd.PersistentFlags().StringVar(&getArgs.statusSelector, "status-selector", "",
|
||||
"specify the status condition name and the desired state to filter the get result, e.g. ready=false")
|
||||
"specify the status condition name and the desired state to filter the get result, e.g. ready=false or ready!=true")
|
||||
getCmd.PersistentFlags().StringVarP(&getArgs.labelSelector, "label-selector", "l", "",
|
||||
"filter objects by label selector")
|
||||
rootCmd.AddCommand(getCmd)
|
||||
@@ -114,6 +114,11 @@ func statusMatches(conditionType, conditionStatus string, conditions []metav1.Co
|
||||
return false
|
||||
}
|
||||
|
||||
func readyStatusMatches(conditionType, conditionStatus string) bool {
|
||||
return strings.EqualFold(conditionType, meta.ReadyCondition) &&
|
||||
strings.EqualFold(conditionStatus, string(metav1.ConditionTrue))
|
||||
}
|
||||
|
||||
func nameColumns(item named, includeNamespace bool, includeKind bool) []string {
|
||||
name := item.GetName()
|
||||
if includeKind {
|
||||
@@ -207,6 +212,9 @@ func (get getCommand) run(cmd *cobra.Command, args []string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if getAll && len(rows) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
err = printers.TablePrinter(header).Print(cmd.OutOrStdout(), rows)
|
||||
if err != nil {
|
||||
@@ -228,20 +236,31 @@ func namespaceNameOrAny(allNamespaces bool, namespaceName string) string {
|
||||
}
|
||||
|
||||
func getRowsToPrint(getAll bool, list summarisable) ([][]string, error) {
|
||||
noFilter := true
|
||||
filter := func(i int) bool { return true }
|
||||
var conditionType, conditionStatus string
|
||||
if getArgs.statusSelector != "" {
|
||||
parts := strings.SplitN(getArgs.statusSelector, "=", 2)
|
||||
// Support both type=status (match) and type!=status (negated match).
|
||||
// "!=" must be checked first since it also contains "=".
|
||||
separator := "="
|
||||
filter = func(i int) bool {
|
||||
return list.statusSelectorMatches(i, conditionType, conditionStatus)
|
||||
}
|
||||
if strings.Contains(getArgs.statusSelector, "!=") {
|
||||
separator = "!="
|
||||
filter = func(i int) bool {
|
||||
return !list.statusSelectorMatches(i, conditionType, conditionStatus)
|
||||
}
|
||||
}
|
||||
parts := strings.SplitN(getArgs.statusSelector, separator, 2)
|
||||
if len(parts) != 2 {
|
||||
return nil, fmt.Errorf("expected status selector in type=status format, but found: %s", getArgs.statusSelector)
|
||||
return nil, fmt.Errorf("expected status selector in type=status or type!=status format, but found: %s", getArgs.statusSelector)
|
||||
}
|
||||
conditionType = parts[0]
|
||||
conditionStatus = parts[1]
|
||||
noFilter = false
|
||||
}
|
||||
var rows [][]string
|
||||
for i := 0; i < list.len(); i++ {
|
||||
if noFilter || list.statusSelectorMatches(i, conditionType, conditionStatus) {
|
||||
if filter(i) {
|
||||
row := list.summariseItem(i, getArgs.allNamespaces, getAll)
|
||||
rows = append(rows, row)
|
||||
}
|
||||
|
||||
@@ -92,5 +92,5 @@ func (s alertListAdapter) headers(includeNamespace bool) []string {
|
||||
}
|
||||
|
||||
func (s alertListAdapter) statusSelectorMatches(i int, conditionType, conditionStatus string) bool {
|
||||
return false
|
||||
return readyStatusMatches(conditionType, conditionStatus)
|
||||
}
|
||||
|
||||
@@ -88,5 +88,5 @@ func (s alertProviderListAdapter) headers(includeNamespace bool) []string {
|
||||
}
|
||||
|
||||
func (s alertProviderListAdapter) statusSelectorMatches(i int, conditionType, conditionStatus string) bool {
|
||||
return false
|
||||
return readyStatusMatches(conditionType, conditionStatus)
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ var getArtifactGeneratorCmd = &cobra.Command{
|
||||
ValidArgsFunction: resourceNamesCompletionFunc(swapi.GroupVersion.WithKind(swapi.ArtifactGeneratorKind)),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
get := getCommand{
|
||||
apiType: receiverType,
|
||||
apiType: artifactGeneratorType,
|
||||
list: artifactGeneratorListAdapter{&swapi.ArtifactGeneratorList{}},
|
||||
funcMap: make(typeMap),
|
||||
}
|
||||
|
||||
@@ -28,13 +28,22 @@ import (
|
||||
helmv2 "github.com/fluxcd/helm-controller/api/v2"
|
||||
)
|
||||
|
||||
type getHelmReleaseFlags struct {
|
||||
showSource bool
|
||||
}
|
||||
|
||||
var getHrArgs getHelmReleaseFlags
|
||||
|
||||
var getHelmReleaseCmd = &cobra.Command{
|
||||
Use: "helmreleases",
|
||||
Aliases: []string{"hr", "helmrelease"},
|
||||
Short: "Get HelmRelease statuses",
|
||||
Long: "The get helmreleases command prints the statuses of the resources.",
|
||||
Example: ` # List all Helm releases and their status
|
||||
flux get helmreleases`,
|
||||
flux get helmreleases
|
||||
|
||||
# List all Helm releases with source information
|
||||
flux get helmreleases --show-source`,
|
||||
ValidArgsFunction: resourceNamesCompletionFunc(helmv2.GroupVersion.WithKind(helmv2.HelmReleaseKind)),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
get := getCommand{
|
||||
@@ -69,6 +78,7 @@ var getHelmReleaseCmd = &cobra.Command{
|
||||
}
|
||||
|
||||
func init() {
|
||||
getHelmReleaseCmd.Flags().BoolVar(&getHrArgs.showSource, "show-source", false, "show the source reference for each helmrelease")
|
||||
getCmd.AddCommand(getHelmReleaseCmd)
|
||||
}
|
||||
|
||||
@@ -79,16 +89,45 @@ func getHelmReleaseRevision(helmRelease helmv2.HelmRelease) string {
|
||||
return helmRelease.Status.LastAttemptedRevision
|
||||
}
|
||||
|
||||
func getHelmReleaseSource(item helmv2.HelmRelease) string {
|
||||
if item.Spec.ChartRef != nil {
|
||||
ns := item.Spec.ChartRef.Namespace
|
||||
if ns == "" {
|
||||
ns = item.GetNamespace()
|
||||
}
|
||||
return fmt.Sprintf("%s/%s/%s",
|
||||
item.Spec.ChartRef.Kind,
|
||||
ns,
|
||||
item.Spec.ChartRef.Name)
|
||||
}
|
||||
ns := item.Spec.Chart.Spec.SourceRef.Namespace
|
||||
if ns == "" {
|
||||
ns = item.GetNamespace()
|
||||
}
|
||||
return fmt.Sprintf("%s/%s/%s",
|
||||
item.Spec.Chart.Spec.SourceRef.Kind,
|
||||
ns,
|
||||
item.Spec.Chart.Spec.SourceRef.Name)
|
||||
}
|
||||
|
||||
func (a helmReleaseListAdapter) summariseItem(i int, includeNamespace bool, includeKind bool) []string {
|
||||
item := a.Items[i]
|
||||
revision := getHelmReleaseRevision(item)
|
||||
status, msg := statusAndMessage(item.Status.Conditions)
|
||||
return append(nameColumns(&item, includeNamespace, includeKind),
|
||||
row := nameColumns(&item, includeNamespace, includeKind)
|
||||
if getHrArgs.showSource {
|
||||
row = append(row, getHelmReleaseSource(item))
|
||||
}
|
||||
return append(row,
|
||||
revision, cases.Title(language.English).String(strconv.FormatBool(item.Spec.Suspend)), status, msg)
|
||||
}
|
||||
|
||||
func (a helmReleaseListAdapter) headers(includeNamespace bool) []string {
|
||||
headers := []string{"Name", "Revision", "Suspended", "Ready", "Message"}
|
||||
headers := []string{"Name"}
|
||||
if getHrArgs.showSource {
|
||||
headers = append(headers, "Source")
|
||||
}
|
||||
headers = append(headers, "Revision", "Suspended", "Ready", "Message")
|
||||
if includeNamespace {
|
||||
headers = append([]string{"Namespace"}, headers...)
|
||||
}
|
||||
|
||||
@@ -30,13 +30,22 @@ import (
|
||||
"github.com/fluxcd/flux2/v2/internal/utils"
|
||||
)
|
||||
|
||||
type getKustomizationFlags struct {
|
||||
showSource bool
|
||||
}
|
||||
|
||||
var getKsArgs getKustomizationFlags
|
||||
|
||||
var getKsCmd = &cobra.Command{
|
||||
Use: "kustomizations",
|
||||
Aliases: []string{"ks", "kustomization"},
|
||||
Short: "Get Kustomization statuses",
|
||||
Long: `The get kustomizations command prints the statuses of the resources.`,
|
||||
Example: ` # List all kustomizations and their status
|
||||
flux get kustomizations`,
|
||||
flux get kustomizations
|
||||
|
||||
# List all kustomizations with source information
|
||||
flux get kustomizations --show-source`,
|
||||
ValidArgsFunction: resourceNamesCompletionFunc(kustomizev1.GroupVersion.WithKind(kustomizev1.KustomizationKind)),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
get := getCommand{
|
||||
@@ -74,6 +83,7 @@ var getKsCmd = &cobra.Command{
|
||||
}
|
||||
|
||||
func init() {
|
||||
getKsCmd.Flags().BoolVar(&getKsArgs.showSource, "show-source", false, "show the source reference for each kustomization")
|
||||
getCmd.AddCommand(getKsCmd)
|
||||
}
|
||||
|
||||
@@ -83,12 +93,27 @@ func (a kustomizationListAdapter) summariseItem(i int, includeNamespace bool, in
|
||||
status, msg := statusAndMessage(item.Status.Conditions)
|
||||
revision = utils.TruncateHex(revision)
|
||||
msg = utils.TruncateHex(msg)
|
||||
return append(nameColumns(&item, includeNamespace, includeKind),
|
||||
row := nameColumns(&item, includeNamespace, includeKind)
|
||||
if getKsArgs.showSource {
|
||||
sourceNs := item.Spec.SourceRef.Namespace
|
||||
if sourceNs == "" {
|
||||
sourceNs = item.GetNamespace()
|
||||
}
|
||||
row = append(row, fmt.Sprintf("%s/%s/%s",
|
||||
item.Spec.SourceRef.Kind,
|
||||
sourceNs,
|
||||
item.Spec.SourceRef.Name))
|
||||
}
|
||||
return append(row,
|
||||
revision, cases.Title(language.English).String(strconv.FormatBool(item.Spec.Suspend)), status, msg)
|
||||
}
|
||||
|
||||
func (a kustomizationListAdapter) headers(includeNamespace bool) []string {
|
||||
headers := []string{"Name", "Revision", "Suspended", "Ready", "Message"}
|
||||
headers := []string{"Name"}
|
||||
if getKsArgs.showSource {
|
||||
headers = append(headers, "Source")
|
||||
}
|
||||
headers = append(headers, "Revision", "Suspended", "Ready", "Message")
|
||||
if includeNamespace {
|
||||
headers = append([]string{"Namespace"}, headers...)
|
||||
}
|
||||
|
||||
@@ -59,6 +59,10 @@ var getSourceAllCmd = &cobra.Command{
|
||||
apiType: helmChartType,
|
||||
list: &helmChartListAdapter{&sourcev1.HelmChartList{}},
|
||||
},
|
||||
{
|
||||
apiType: externalArtifactType,
|
||||
list: &externalArtifactListAdapter{&sourcev1.ExternalArtifactList{}},
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range allSourceCmd {
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
/*
|
||||
Copyright 2025 The Flux authors
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
|
||||
sourcev1 "github.com/fluxcd/source-controller/api/v1"
|
||||
|
||||
"github.com/fluxcd/flux2/v2/internal/utils"
|
||||
)
|
||||
|
||||
var getSourceExternalCmd = &cobra.Command{
|
||||
Use: "external",
|
||||
Short: "Get ExternalArtifact source statuses",
|
||||
Long: `The get sources external command prints the status of the ExternalArtifact sources.`,
|
||||
Example: ` # List all ExternalArtifacts and their status
|
||||
flux get sources external
|
||||
|
||||
# List ExternalArtifacts from all namespaces
|
||||
flux get sources external --all-namespaces`,
|
||||
ValidArgsFunction: resourceNamesCompletionFunc(sourcev1.GroupVersion.WithKind(sourcev1.ExternalArtifactKind)),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
get := getCommand{
|
||||
apiType: externalArtifactType,
|
||||
list: &externalArtifactListAdapter{&sourcev1.ExternalArtifactList{}},
|
||||
funcMap: make(typeMap),
|
||||
}
|
||||
|
||||
err := get.funcMap.registerCommand(get.apiType.kind, func(obj runtime.Object) (summarisable, error) {
|
||||
o, ok := obj.(*sourcev1.ExternalArtifact)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("impossible to cast type %#v to ExternalArtifact", obj)
|
||||
}
|
||||
|
||||
sink := &externalArtifactListAdapter{&sourcev1.ExternalArtifactList{
|
||||
Items: []sourcev1.ExternalArtifact{
|
||||
*o,
|
||||
}}}
|
||||
return sink, nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := get.run(cmd, args); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
getSourceCmd.AddCommand(getSourceExternalCmd)
|
||||
}
|
||||
|
||||
func (a *externalArtifactListAdapter) summariseItem(i int, includeNamespace bool, includeKind bool) []string {
|
||||
item := a.Items[i]
|
||||
var revision string
|
||||
if item.Status.Artifact != nil {
|
||||
revision = item.Status.Artifact.Revision
|
||||
}
|
||||
status, msg := statusAndMessage(item.Status.Conditions)
|
||||
revision = utils.TruncateHex(revision)
|
||||
msg = utils.TruncateHex(msg)
|
||||
|
||||
var source string
|
||||
if item.Spec.SourceRef != nil {
|
||||
source = fmt.Sprintf("%s/%s/%s",
|
||||
item.Spec.SourceRef.Kind,
|
||||
item.Spec.SourceRef.Namespace,
|
||||
item.Spec.SourceRef.Name)
|
||||
}
|
||||
return append(nameColumns(&item, includeNamespace, includeKind),
|
||||
revision, source, status, msg)
|
||||
}
|
||||
|
||||
func (a externalArtifactListAdapter) headers(includeNamespace bool) []string {
|
||||
headers := []string{"Name", "Revision", "Source", "Ready", "Message"}
|
||||
if includeNamespace {
|
||||
headers = append([]string{"Namespace"}, headers...)
|
||||
}
|
||||
return headers
|
||||
}
|
||||
|
||||
func (a externalArtifactListAdapter) statusSelectorMatches(i int, conditionType, conditionStatus string) bool {
|
||||
item := a.Items[i]
|
||||
return statusMatches(conditionType, conditionStatus, item.Status.Conditions)
|
||||
}
|
||||
@@ -63,6 +63,141 @@ func Test_GetCmd(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func Test_GetCmdStatusSelector(t *testing.T) {
|
||||
tmpl := map[string]string{
|
||||
"fluxns": allocateNamespace("flux-system"),
|
||||
}
|
||||
testEnv.CreateObjectFile("./testdata/get/status_objects.yaml", tmpl, t)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
args string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "equal status selector matches one",
|
||||
args: "--status-selector Ready=True",
|
||||
expected: "testdata/get/get_status_ready_true.golden",
|
||||
},
|
||||
{
|
||||
name: "equal status selector matches false",
|
||||
args: "--status-selector Ready=False",
|
||||
expected: "testdata/get/get_status_ready_false.golden",
|
||||
},
|
||||
{
|
||||
name: "not-equal status selector matches all not-true",
|
||||
args: "--status-selector Ready!=True",
|
||||
expected: "testdata/get/get_status_ready_not_true.golden",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cmd := cmdTestCase{
|
||||
args: "get sources git " + tt.args + " -n " + tmpl["fluxns"],
|
||||
assert: assertGoldenTemplateFile(tt.expected, nil),
|
||||
}
|
||||
|
||||
cmd.runTestCmd(t)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_GetCmdStatusSelectorSyntheticReady(t *testing.T) {
|
||||
tmpl := map[string]string{
|
||||
"fluxns": allocateNamespace("flux-system"),
|
||||
}
|
||||
testEnv.CreateObjectFile("./testdata/get/notification_objects.yaml", tmpl, t)
|
||||
|
||||
commands := []string{
|
||||
"get alerts",
|
||||
"get alert-providers",
|
||||
"get all",
|
||||
}
|
||||
for _, command := range commands {
|
||||
t.Run(command, func(t *testing.T) {
|
||||
unfilteredOutput, err := executeCommand(command + " -n " + tmpl["fluxns"])
|
||||
if err != nil {
|
||||
t.Fatalf("%s failed: %v", command, err)
|
||||
}
|
||||
if unfilteredOutput == "" {
|
||||
t.Fatalf("expected %s output for namespace with notification objects", command)
|
||||
}
|
||||
|
||||
filteredOutput, err := executeCommand(command + " --status-selector Ready=True -n " + tmpl["fluxns"])
|
||||
if err != nil {
|
||||
t.Fatalf("%s with Ready=True status selector failed: %v", command, err)
|
||||
}
|
||||
|
||||
if filteredOutput != unfilteredOutput {
|
||||
t.Fatalf("expected Ready=True filtered output to match unfiltered output:\nfiltered:\n%s\nunfiltered:\n%s", filteredOutput, unfilteredOutput)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_GetAllCmdStatusSelectorNoMatches(t *testing.T) {
|
||||
tmpl := map[string]string{
|
||||
"fluxns": allocateNamespace("flux-system"),
|
||||
}
|
||||
testEnv.CreateObjectFile("./testdata/get/status_objects.yaml", tmpl, t)
|
||||
|
||||
cmd := cmdTestCase{
|
||||
args: "get all --status-selector foo=bar -n " + tmpl["fluxns"],
|
||||
assert: assertGoldenValue(""),
|
||||
}
|
||||
|
||||
cmd.runTestCmd(t)
|
||||
}
|
||||
|
||||
func Test_GetAllCmdStatusSelectorKustomizationOnlyMatches(t *testing.T) {
|
||||
tmpl := map[string]string{
|
||||
"fluxns": allocateNamespace("flux-system"),
|
||||
}
|
||||
testEnv.CreateObjectFile("./testdata/get/kustomization_only.yaml", tmpl, t)
|
||||
|
||||
unfilteredOutput, err := executeCommand("get all -n " + tmpl["fluxns"])
|
||||
if err != nil {
|
||||
t.Fatalf("get all failed: %v", err)
|
||||
}
|
||||
if unfilteredOutput == "" {
|
||||
t.Fatal("expected get all output for namespace with one Kustomization")
|
||||
}
|
||||
|
||||
filteredOutput, err := executeCommand("get all --status-selector Ready=True -n " + tmpl["fluxns"])
|
||||
if err != nil {
|
||||
t.Fatalf("get all with matching status selector failed: %v", err)
|
||||
}
|
||||
|
||||
if filteredOutput != unfilteredOutput {
|
||||
t.Fatalf("expected filtered output to match unfiltered output:\nfiltered:\n%s\nunfiltered:\n%s", filteredOutput, unfilteredOutput)
|
||||
}
|
||||
}
|
||||
|
||||
func Test_GetAllCmdStatusSelectorKustomizationOnlyNoMatch(t *testing.T) {
|
||||
tmpl := map[string]string{
|
||||
"fluxns": allocateNamespace("flux-system"),
|
||||
}
|
||||
testEnv.CreateObjectFile("./testdata/get/kustomization_only.yaml", tmpl, t)
|
||||
|
||||
emptyNamespace := allocateNamespace("empty")
|
||||
setupTestNamespace(emptyNamespace, t)
|
||||
|
||||
emptyOutput, err := executeCommand("get all -n " + emptyNamespace)
|
||||
if err != nil {
|
||||
t.Fatalf("get all in empty namespace failed: %v", err)
|
||||
}
|
||||
|
||||
filteredOutput, err := executeCommand("get all --status-selector Ready=False -n " + tmpl["fluxns"])
|
||||
if err != nil {
|
||||
t.Fatalf("get all with non-matching status selector failed: %v", err)
|
||||
}
|
||||
|
||||
if filteredOutput != emptyOutput {
|
||||
t.Fatalf("expected filtered output to match empty namespace output:\nfiltered:\n%s\nempty namespace:\n%s", filteredOutput, emptyOutput)
|
||||
}
|
||||
}
|
||||
|
||||
func Test_GetCmdErrors(t *testing.T) {
|
||||
tmpl := map[string]string{
|
||||
"fluxns": allocateNamespace("flux-system"),
|
||||
@@ -84,6 +219,16 @@ func Test_GetCmdErrors(t *testing.T) {
|
||||
args: "get helmrelease -n " + tmpl["fluxns"],
|
||||
assert: assertError(fmt.Sprintf("no HelmRelease objects found in \"%s\" namespace", tmpl["fluxns"])),
|
||||
},
|
||||
{
|
||||
name: "no artifact generators found in namespace",
|
||||
args: "get artifact generators -n " + tmpl["fluxns"],
|
||||
assert: assertError(fmt.Sprintf("no ArtifactGenerator objects found in \"%s\" namespace", tmpl["fluxns"])),
|
||||
},
|
||||
{
|
||||
name: "malformed status selector",
|
||||
args: "get sources git --status-selector Ready -n " + tmpl["fluxns"],
|
||||
assert: assertError("expected status selector in type=status or type!=status format, but found: Ready"),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
@@ -52,7 +52,7 @@ var listArtifactsCmd = &cobra.Command{
|
||||
Long: `The list command fetches the tags and their metadata from a remote OCI repository.
|
||||
The command can read the credentials from '~/.docker/config.json' but they can also be passed with --creds. It can also login to a supported provider with the --provider flag.`,
|
||||
Example: ` # List the artifacts stored in an OCI repository
|
||||
flux list artifact oci://ghcr.io/org/config/app
|
||||
flux list artifacts oci://ghcr.io/org/config/app
|
||||
`,
|
||||
RunE: listArtifactsCmdRun,
|
||||
}
|
||||
@@ -85,7 +85,7 @@ func listArtifactsCmdRun(cmd *cobra.Command, args []string) error {
|
||||
|
||||
if listArtifactArgs.provider.String() != sourcev1.GenericOCIProvider {
|
||||
logger.Actionf("logging in to registry with provider credentials")
|
||||
ociOpt, err := loginWithProvider(ctx, url, listArtifactArgs.provider.String())
|
||||
ociOpt, _, err := loginWithProvider(ctx, url, listArtifactArgs.provider.String())
|
||||
if err != nil {
|
||||
return fmt.Errorf("error during login with provider: %w", err)
|
||||
}
|
||||
|
||||
+43
-6
@@ -100,6 +100,16 @@ Command line utility for assembling Kubernetes CD pipelines the GitOps way.`,
|
||||
# Uninstall Flux and delete CRDs
|
||||
flux uninstall`,
|
||||
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
|
||||
// If opted in via --ns-follows-kube-context flag or
|
||||
// FLUX_NS_FOLLOWS_KUBE_CONTEXT env var, and --namespace was not
|
||||
// explicitly set, respect the namespace from the kubeconfig context.
|
||||
if !cmd.Flags().Changed("namespace") &&
|
||||
(rootArgs.nsFollowsKubeContext || os.Getenv("FLUX_NS_FOLLOWS_KUBE_CONTEXT") != "") {
|
||||
if ctxNs := getKubeconfigContextNamespace(kubeconfigArgs); ctxNs != "" {
|
||||
*kubeconfigArgs.Namespace = ctxNs
|
||||
}
|
||||
}
|
||||
|
||||
ns, err := cmd.Flags().GetString("namespace")
|
||||
if err != nil {
|
||||
return fmt.Errorf("error getting namespace: %w", err)
|
||||
@@ -116,10 +126,11 @@ Command line utility for assembling Kubernetes CD pipelines the GitOps way.`,
|
||||
var logger = stderrLogger{stderr: os.Stderr}
|
||||
|
||||
type rootFlags struct {
|
||||
timeout time.Duration
|
||||
verbose bool
|
||||
pollInterval time.Duration
|
||||
defaults install.Options
|
||||
timeout time.Duration
|
||||
verbose bool
|
||||
pollInterval time.Duration
|
||||
nsFollowsKubeContext bool
|
||||
defaults install.Options
|
||||
}
|
||||
|
||||
// RequestError is a custom error type that wraps an error returned by the flux api.
|
||||
@@ -139,6 +150,8 @@ var kubeclientOptions = new(runclient.Options)
|
||||
func init() {
|
||||
rootCmd.PersistentFlags().DurationVar(&rootArgs.timeout, "timeout", 5*time.Minute, "timeout for this operation")
|
||||
rootCmd.PersistentFlags().BoolVar(&rootArgs.verbose, "verbose", false, "print generated objects")
|
||||
rootCmd.PersistentFlags().BoolVar(&rootArgs.nsFollowsKubeContext, "ns-follows-kube-context", false,
|
||||
"use the namespace from the kubeconfig context instead of the default flux-system namespace, can also be set via FLUX_NS_FOLLOWS_KUBE_CONTEXT env var")
|
||||
|
||||
configureDefaultNamespace()
|
||||
kubeconfigArgs.APIServer = nil // prevent AddFlags from configuring --server flag
|
||||
@@ -180,12 +193,14 @@ func main() {
|
||||
|
||||
// This is required because controller-runtime expects its consumers to
|
||||
// set a logger through log.SetLogger within 30 seconds of the program's
|
||||
// initalization. If not set, the entire debug stack is printed as an
|
||||
// initialization. If not set, the entire debug stack is printed as an
|
||||
// error, see: https://github.com/kubernetes-sigs/controller-runtime/blob/ed8be90/pkg/log/log.go#L59
|
||||
// Since we have our own logging and don't care about controller-runtime's
|
||||
// logger, we configure it's logger to do nothing.
|
||||
ctrllog.SetLogger(logr.New(ctrllog.NullLogSink{}))
|
||||
|
||||
registerPlugins()
|
||||
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
|
||||
if err, ok := err.(*RequestError); ok {
|
||||
@@ -203,6 +218,26 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
// getKubeconfigContextNamespace returns the namespace from the current
|
||||
// kubeconfig context, or an empty string if it cannot be determined.
|
||||
func getKubeconfigContextNamespace(cf *genericclioptions.ConfigFlags) string {
|
||||
rawConfig, err := cf.ToRawKubeConfigLoader().RawConfig()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
currentContext := rawConfig.CurrentContext
|
||||
if cf.Context != nil && *cf.Context != "" {
|
||||
currentContext = *cf.Context
|
||||
}
|
||||
|
||||
if ctx, ok := rawConfig.Contexts[currentContext]; ok {
|
||||
return ctx.Namespace
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func configureDefaultNamespace() {
|
||||
*kubeconfigArgs.Namespace = rootArgs.defaults.Namespace
|
||||
fromEnv := os.Getenv("FLUX_SYSTEM_NAMESPACE")
|
||||
@@ -225,7 +260,9 @@ func configureDefaultNamespace() {
|
||||
func readPasswordFromStdin(prompt string) (string, error) {
|
||||
var out string
|
||||
var err error
|
||||
fmt.Fprint(os.Stdout, prompt)
|
||||
if _, err := fmt.Fprint(os.Stdout, prompt); err != nil {
|
||||
return "", fmt.Errorf("failed to write prompt: %w", err)
|
||||
}
|
||||
stdinFD := int(os.Stdin.Fd())
|
||||
if term.IsTerminal(stdinFD) {
|
||||
var inBytes []byte
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
/*
|
||||
Copyright 2026 The Flux authors
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
. "github.com/onsi/gomega"
|
||||
"k8s.io/cli-runtime/pkg/genericclioptions"
|
||||
)
|
||||
|
||||
func TestGetKubeconfigContextNamespace(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
kubeconfig string
|
||||
context string
|
||||
expectedResult string
|
||||
}{
|
||||
{
|
||||
name: "returns namespace from current context",
|
||||
kubeconfig: `apiVersion: v1
|
||||
kind: Config
|
||||
current-context: my-context
|
||||
contexts:
|
||||
- name: my-context
|
||||
context:
|
||||
cluster: my-cluster
|
||||
namespace: custom-ns
|
||||
clusters:
|
||||
- name: my-cluster
|
||||
cluster:
|
||||
server: https://localhost:6443
|
||||
`,
|
||||
expectedResult: "custom-ns",
|
||||
},
|
||||
{
|
||||
name: "returns empty when context has no namespace",
|
||||
kubeconfig: `apiVersion: v1
|
||||
kind: Config
|
||||
current-context: my-context
|
||||
contexts:
|
||||
- name: my-context
|
||||
context:
|
||||
cluster: my-cluster
|
||||
clusters:
|
||||
- name: my-cluster
|
||||
cluster:
|
||||
server: https://localhost:6443
|
||||
`,
|
||||
expectedResult: "",
|
||||
},
|
||||
{
|
||||
name: "returns namespace from context specified via --context flag",
|
||||
kubeconfig: `apiVersion: v1
|
||||
kind: Config
|
||||
current-context: default-context
|
||||
contexts:
|
||||
- name: default-context
|
||||
context:
|
||||
cluster: my-cluster
|
||||
namespace: default-ns
|
||||
- name: other-context
|
||||
context:
|
||||
cluster: my-cluster
|
||||
namespace: other-ns
|
||||
clusters:
|
||||
- name: my-cluster
|
||||
cluster:
|
||||
server: https://localhost:6443
|
||||
`,
|
||||
context: "other-context",
|
||||
expectedResult: "other-ns",
|
||||
},
|
||||
{
|
||||
name: "returns empty when context does not exist",
|
||||
kubeconfig: `apiVersion: v1
|
||||
kind: Config
|
||||
current-context: non-existent
|
||||
contexts: []
|
||||
clusters: []
|
||||
`,
|
||||
expectedResult: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
g := NewWithT(t)
|
||||
|
||||
// Write temporary kubeconfig.
|
||||
tmpDir := t.TempDir()
|
||||
kcPath := filepath.Join(tmpDir, "kubeconfig")
|
||||
g.Expect(os.WriteFile(kcPath, []byte(tt.kubeconfig), 0o600)).To(Succeed())
|
||||
|
||||
// Use a local ConfigFlags instance to avoid polluting the
|
||||
// package-global kubeconfigArgs (which caches a clientConfig
|
||||
// internally and would leak state across tests).
|
||||
cf := genericclioptions.NewConfigFlags(false)
|
||||
cf.KubeConfig = &kcPath
|
||||
cf.Context = &tt.context
|
||||
|
||||
got := getKubeconfigContextNamespace(cf)
|
||||
g.Expect(got).To(Equal(tt.expectedResult))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextNamespaceOptIn(t *testing.T) {
|
||||
kubeconfig := `apiVersion: v1
|
||||
kind: Config
|
||||
current-context: my-context
|
||||
contexts:
|
||||
- name: my-context
|
||||
context:
|
||||
cluster: my-cluster
|
||||
namespace: context-ns
|
||||
clusters:
|
||||
- name: my-cluster
|
||||
cluster:
|
||||
server: https://localhost:6443
|
||||
`
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
nsFollowsFlag bool
|
||||
nsFollowsEnv string
|
||||
envNamespace string
|
||||
flagNamespace string
|
||||
expectedNamespace string
|
||||
}{
|
||||
{
|
||||
name: "ignores context namespace when not opted in",
|
||||
expectedNamespace: rootArgs.defaults.Namespace,
|
||||
},
|
||||
{
|
||||
name: "uses context namespace when opted in via flag",
|
||||
nsFollowsFlag: true,
|
||||
expectedNamespace: "context-ns",
|
||||
},
|
||||
{
|
||||
name: "uses context namespace when opted in via env var",
|
||||
nsFollowsEnv: "1",
|
||||
expectedNamespace: "context-ns",
|
||||
},
|
||||
{
|
||||
name: "context namespace takes precedence over FLUX_SYSTEM_NAMESPACE when opted in",
|
||||
nsFollowsFlag: true,
|
||||
envNamespace: "env-ns",
|
||||
expectedNamespace: "context-ns",
|
||||
},
|
||||
{
|
||||
name: "FLUX_SYSTEM_NAMESPACE used when not opted in",
|
||||
envNamespace: "env-ns",
|
||||
expectedNamespace: "env-ns",
|
||||
},
|
||||
{
|
||||
name: "--namespace flag takes precedence over context namespace",
|
||||
nsFollowsFlag: true,
|
||||
flagNamespace: "flag-ns",
|
||||
expectedNamespace: "flag-ns",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
g := NewWithT(t)
|
||||
|
||||
// Write temporary kubeconfig.
|
||||
tmpDir := t.TempDir()
|
||||
kcPath := filepath.Join(tmpDir, "kubeconfig")
|
||||
g.Expect(os.WriteFile(kcPath, []byte(kubeconfig), 0o600)).To(Succeed())
|
||||
|
||||
// Use a local ConfigFlags instance to avoid polluting the
|
||||
// package-global kubeconfigArgs.
|
||||
cf := genericclioptions.NewConfigFlags(false)
|
||||
cf.KubeConfig = &kcPath
|
||||
emptyCtx := ""
|
||||
cf.Context = &emptyCtx
|
||||
|
||||
// Mirror configureDefaultNamespace behavior on the local instance.
|
||||
defaultNs := rootArgs.defaults.Namespace
|
||||
cf.Namespace = &defaultNs
|
||||
|
||||
if tt.envNamespace != "" {
|
||||
t.Setenv("FLUX_SYSTEM_NAMESPACE", tt.envNamespace)
|
||||
envNs := tt.envNamespace
|
||||
cf.Namespace = &envNs
|
||||
}
|
||||
if tt.nsFollowsEnv != "" {
|
||||
t.Setenv("FLUX_NS_FOLLOWS_KUBE_CONTEXT", tt.nsFollowsEnv)
|
||||
}
|
||||
|
||||
// Simulate PersistentPreRunE behavior.
|
||||
if tt.flagNamespace != "" {
|
||||
*cf.Namespace = tt.flagNamespace
|
||||
} else if tt.nsFollowsFlag || os.Getenv("FLUX_NS_FOLLOWS_KUBE_CONTEXT") != "" {
|
||||
if ctxNs := getKubeconfigContextNamespace(cf); ctxNs != "" {
|
||||
*cf.Namespace = ctxNs
|
||||
}
|
||||
}
|
||||
|
||||
g.Expect(*cf.Namespace).To(Equal(tt.expectedNamespace))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -374,6 +374,12 @@ func executeCommand(cmd string) (string, error) {
|
||||
// in subsequent executions which causes tests to fail that rely on the value
|
||||
// of "Changed".
|
||||
resumeCmd.PersistentFlags().Lookup("wait").Changed = false
|
||||
// Reset the help flag value and Changed state so that a prior
|
||||
// "--help" invocation does not leak into subsequent test runs.
|
||||
if hf := rootCmd.Flags().Lookup("help"); hf != nil {
|
||||
hf.Value.Set("false")
|
||||
hf.Changed = false
|
||||
}
|
||||
}()
|
||||
args, err := shellwords.Parse(cmd)
|
||||
if err != nil {
|
||||
@@ -454,7 +460,9 @@ func resetCmdArgs() {
|
||||
rhrArgs = reconcileHelmReleaseFlags{}
|
||||
rksArgs = reconcileKsFlags{}
|
||||
secretGitArgs = NewSecretGitFlags()
|
||||
secretGitHubAppArgs = secretGitHubAppFlags{}
|
||||
secretProxyArgs = secretProxyFlags{}
|
||||
secretReceiverArgs = secretReceiverFlags{}
|
||||
secretHelmArgs = secretHelmFlags{}
|
||||
secretTLSArgs = secretTLSFlags{}
|
||||
sourceBucketArgs = sourceBucketFlags{}
|
||||
|
||||
+548
-30
@@ -18,41 +18,200 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/fluxcd/pkg/ssa"
|
||||
"github.com/manifoldco/promptui"
|
||||
"github.com/spf13/cobra"
|
||||
apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
"k8s.io/client-go/util/retry"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
|
||||
helmv2 "github.com/fluxcd/helm-controller/api/v2"
|
||||
imageautov1 "github.com/fluxcd/image-automation-controller/api/v1"
|
||||
imageautov1b2 "github.com/fluxcd/image-automation-controller/api/v1beta2"
|
||||
imagev1 "github.com/fluxcd/image-reflector-controller/api/v1"
|
||||
imagev1b2 "github.com/fluxcd/image-reflector-controller/api/v1beta2"
|
||||
kustomizev1 "github.com/fluxcd/kustomize-controller/api/v1"
|
||||
notificationv1 "github.com/fluxcd/notification-controller/api/v1"
|
||||
notificationv1b3 "github.com/fluxcd/notification-controller/api/v1beta3"
|
||||
sourcev1 "github.com/fluxcd/source-controller/api/v1"
|
||||
swv1b1 "github.com/fluxcd/source-watcher/api/v2/v1beta1"
|
||||
|
||||
"github.com/fluxcd/flux2/v2/internal/utils"
|
||||
)
|
||||
|
||||
// APIVersions holds the mapping of GroupKinds to their respective
|
||||
// latest API versions for a specific Flux version.
|
||||
type APIVersions struct {
|
||||
FluxVersion string
|
||||
LatestVersions map[schema.GroupKind]string
|
||||
}
|
||||
|
||||
// TODO: Update this mapping when new Flux minor versions are released!
|
||||
// latestAPIVersions contains the latest API versions for each GroupKind
|
||||
// for each supported Flux version. The number of latest minor versions
|
||||
// we maintain here must match what's documented here:
|
||||
//
|
||||
// https://fluxcd.io/flux/releases/#supported-releases
|
||||
var latestAPIVersions = []APIVersions{
|
||||
{
|
||||
FluxVersion: "2.8",
|
||||
LatestVersions: flux27LatestAPIVersions,
|
||||
},
|
||||
{
|
||||
FluxVersion: "2.7",
|
||||
LatestVersions: flux27LatestAPIVersions,
|
||||
},
|
||||
{
|
||||
FluxVersion: "2.6",
|
||||
LatestVersions: flux26LatestAPIVersions,
|
||||
},
|
||||
}
|
||||
|
||||
var flux27LatestAPIVersions = map[schema.GroupKind]string{
|
||||
// source-controller
|
||||
{Group: sourcev1.GroupVersion.Group, Kind: sourcev1.BucketKind}: sourcev1.GroupVersion.Version,
|
||||
{Group: sourcev1.GroupVersion.Group, Kind: sourcev1.GitRepositoryKind}: sourcev1.GroupVersion.Version,
|
||||
{Group: sourcev1.GroupVersion.Group, Kind: sourcev1.OCIRepositoryKind}: sourcev1.GroupVersion.Version,
|
||||
{Group: sourcev1.GroupVersion.Group, Kind: sourcev1.HelmRepositoryKind}: sourcev1.GroupVersion.Version,
|
||||
{Group: sourcev1.GroupVersion.Group, Kind: sourcev1.HelmChartKind}: sourcev1.GroupVersion.Version,
|
||||
{Group: sourcev1.GroupVersion.Group, Kind: sourcev1.ExternalArtifactKind}: sourcev1.GroupVersion.Version,
|
||||
|
||||
// kustomize-controller
|
||||
{Group: kustomizev1.GroupVersion.Group, Kind: kustomizev1.KustomizationKind}: kustomizev1.GroupVersion.Version,
|
||||
|
||||
// helm-controller
|
||||
{Group: helmv2.GroupVersion.Group, Kind: helmv2.HelmReleaseKind}: helmv2.GroupVersion.Version,
|
||||
|
||||
// notification-controller
|
||||
{Group: notificationv1.GroupVersion.Group, Kind: notificationv1.ReceiverKind}: notificationv1.GroupVersion.Version,
|
||||
{Group: notificationv1b3.GroupVersion.Group, Kind: notificationv1b3.AlertKind}: notificationv1b3.GroupVersion.Version,
|
||||
{Group: notificationv1b3.GroupVersion.Group, Kind: notificationv1b3.ProviderKind}: notificationv1b3.GroupVersion.Version,
|
||||
|
||||
// image-reflector-controller
|
||||
{Group: imagev1.GroupVersion.Group, Kind: imagev1.ImageRepositoryKind}: imagev1.GroupVersion.Version,
|
||||
{Group: imagev1.GroupVersion.Group, Kind: imagev1.ImagePolicyKind}: imagev1.GroupVersion.Version,
|
||||
|
||||
// image-automation-controller
|
||||
{Group: imageautov1.GroupVersion.Group, Kind: imageautov1.ImageUpdateAutomationKind}: imageautov1.GroupVersion.Version,
|
||||
|
||||
// source-watcher
|
||||
{Group: swv1b1.GroupVersion.Group, Kind: swv1b1.ArtifactGeneratorKind}: swv1b1.GroupVersion.Version,
|
||||
}
|
||||
|
||||
var flux26LatestAPIVersions = map[schema.GroupKind]string{
|
||||
// source-controller
|
||||
{Group: sourcev1.GroupVersion.Group, Kind: sourcev1.BucketKind}: sourcev1.GroupVersion.Version,
|
||||
{Group: sourcev1.GroupVersion.Group, Kind: sourcev1.GitRepositoryKind}: sourcev1.GroupVersion.Version,
|
||||
{Group: sourcev1.GroupVersion.Group, Kind: sourcev1.OCIRepositoryKind}: sourcev1.GroupVersion.Version,
|
||||
{Group: sourcev1.GroupVersion.Group, Kind: sourcev1.HelmRepositoryKind}: sourcev1.GroupVersion.Version,
|
||||
{Group: sourcev1.GroupVersion.Group, Kind: sourcev1.HelmChartKind}: sourcev1.GroupVersion.Version,
|
||||
{Group: sourcev1.GroupVersion.Group, Kind: sourcev1.ExternalArtifactKind}: sourcev1.GroupVersion.Version,
|
||||
|
||||
// kustomize-controller
|
||||
{Group: kustomizev1.GroupVersion.Group, Kind: kustomizev1.KustomizationKind}: kustomizev1.GroupVersion.Version,
|
||||
|
||||
// helm-controller
|
||||
{Group: helmv2.GroupVersion.Group, Kind: helmv2.HelmReleaseKind}: helmv2.GroupVersion.Version,
|
||||
|
||||
// notification-controller
|
||||
{Group: notificationv1.GroupVersion.Group, Kind: notificationv1.ReceiverKind}: notificationv1.GroupVersion.Version,
|
||||
{Group: notificationv1b3.GroupVersion.Group, Kind: notificationv1b3.AlertKind}: notificationv1b3.GroupVersion.Version,
|
||||
{Group: notificationv1b3.GroupVersion.Group, Kind: notificationv1b3.ProviderKind}: notificationv1b3.GroupVersion.Version,
|
||||
|
||||
// image-reflector-controller
|
||||
{Group: imagev1b2.GroupVersion.Group, Kind: imagev1b2.ImageRepositoryKind}: imagev1b2.GroupVersion.Version,
|
||||
{Group: imagev1b2.GroupVersion.Group, Kind: imagev1b2.ImagePolicyKind}: imagev1b2.GroupVersion.Version,
|
||||
|
||||
// image-automation-controller
|
||||
{Group: imageautov1b2.GroupVersion.Group, Kind: imageautov1b2.ImageUpdateAutomationKind}: imageautov1b2.GroupVersion.Version,
|
||||
}
|
||||
|
||||
var migrateCmd = &cobra.Command{
|
||||
Use: "migrate",
|
||||
Args: cobra.NoArgs,
|
||||
Short: "Migrate the Flux custom resources to their latest API version",
|
||||
Long: `The migrate command must be run before a Flux minor version upgrade.
|
||||
The command migrates the Flux custom resources stored in Kubernetes etcd to their latest API version,
|
||||
ensuring the Flux components can continue to function correctly after the upgrade.
|
||||
|
||||
The command has two modes of operation:
|
||||
|
||||
- Cluster mode (default): migrates all the Flux custom resources stored in Kubernetes etcd to their latest API version.
|
||||
- File system mode (-f): migrates the Flux custom resources defined in the manifests located in the specified path.
|
||||
`,
|
||||
Example: ` # Migrate all the Flux custom resources in the cluster.
|
||||
# This uses the current kubeconfig context and requires cluster-admin permissions.
|
||||
flux migrate
|
||||
|
||||
# Migrate all the Flux custom resources in a Git repository
|
||||
# checked out in the current working directory.
|
||||
flux migrate -f .
|
||||
|
||||
# Migrate all Flux custom resources defined in YAML and Helm YAML template files.
|
||||
flux migrate -f . --extensions=.yml,.yaml,.tpl
|
||||
|
||||
# Migrate the Flux custom resources to the latest API versions of Flux 2.6.
|
||||
flux migrate -f . --version=2.6
|
||||
|
||||
# Migrate the Flux custom resources defined in a multi-document YAML manifest file.
|
||||
flux migrate -f path/to/manifest.yaml
|
||||
|
||||
# Simulate the migration without making any changes.
|
||||
flux migrate -f . --dry-run
|
||||
|
||||
# Run the migration skipping confirmation prompts.
|
||||
flux migrate -f . --yes
|
||||
`,
|
||||
RunE: runMigrateCmd,
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(migrateCmd)
|
||||
var migrateFlags struct {
|
||||
yes bool
|
||||
dryRun bool
|
||||
path string
|
||||
version string
|
||||
extensions []string
|
||||
}
|
||||
|
||||
func runMigrateCmd(cmd *cobra.Command, args []string) error {
|
||||
func init() {
|
||||
rootCmd.AddCommand(migrateCmd)
|
||||
|
||||
migrateCmd.Flags().StringVarP(&migrateFlags.path, "path", "f", "",
|
||||
"the path to the directory containing the manifests to migrate")
|
||||
migrateCmd.Flags().StringSliceVarP(&migrateFlags.extensions, "extensions", "e", []string{".yaml", ".yml"},
|
||||
"the file extensions to consider when migrating manifests, only applicable with --path")
|
||||
migrateCmd.Flags().StringVarP(&migrateFlags.version, "version", "v", "",
|
||||
"the target Flux minor version to migrate manifests to, only applicable with --path (defaults to the version of the CLI)")
|
||||
migrateCmd.Flags().BoolVarP(&migrateFlags.yes, "yes", "y", false,
|
||||
"skip confirmation prompts when migrating manifests, only applicable with --path")
|
||||
migrateCmd.Flags().BoolVar(&migrateFlags.dryRun, "dry-run", false,
|
||||
"simulate the migration of manifests without making any changes, only applicable with --path")
|
||||
}
|
||||
|
||||
func runMigrateCmd(*cobra.Command, []string) error {
|
||||
if migrateFlags.path == "" {
|
||||
return migrateCluster()
|
||||
}
|
||||
return migrateFileSystem()
|
||||
}
|
||||
|
||||
func migrateCluster() error {
|
||||
logger.Actionf("starting migration of custom resources")
|
||||
ctx, cancel := context.WithTimeout(context.Background(), rootArgs.timeout)
|
||||
defer cancel()
|
||||
|
||||
cfg, err := utils.KubeConfig(kubeconfigArgs, kubeclientOptions)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Kubernetes client initialization failed: %s", err.Error())
|
||||
return fmt.Errorf("the Kubernetes client initialization failed: %w", err)
|
||||
}
|
||||
|
||||
kubeClient, err := client.New(cfg, client.Options{Scheme: utils.NewScheme()})
|
||||
@@ -60,7 +219,7 @@ func runMigrateCmd(cmd *cobra.Command, args []string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
migrator := NewMigrator(kubeClient, client.MatchingLabels{
|
||||
migrator := NewClusterMigrator(kubeClient, client.MatchingLabels{
|
||||
"app.kubernetes.io/part-of": "flux",
|
||||
})
|
||||
|
||||
@@ -72,28 +231,64 @@ func runMigrateCmd(cmd *cobra.Command, args []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type Migrator struct {
|
||||
func migrateFileSystem() error {
|
||||
pathRoot, err := os.OpenRoot(".")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open filesystem at the current working directory: %w", err)
|
||||
}
|
||||
defer pathRoot.Close()
|
||||
|
||||
fileSystem := &osFS{pathRoot.FS()}
|
||||
yes := migrateFlags.yes
|
||||
dryRun := migrateFlags.dryRun
|
||||
path := migrateFlags.path
|
||||
extensions := migrateFlags.extensions
|
||||
var latestVersions map[schema.GroupKind]string
|
||||
|
||||
// Determine latest API versions based on the Flux version.
|
||||
if migrateFlags.version == "" {
|
||||
latestVersions = latestAPIVersions[0].LatestVersions
|
||||
} else {
|
||||
supportedVersions := make([]string, 0, len(latestAPIVersions))
|
||||
for _, v := range latestAPIVersions {
|
||||
if v.FluxVersion == migrateFlags.version {
|
||||
latestVersions = v.LatestVersions
|
||||
break
|
||||
}
|
||||
supportedVersions = append(supportedVersions, v.FluxVersion)
|
||||
}
|
||||
if latestVersions == nil {
|
||||
return fmt.Errorf("version %s is not supported, supported versions are: %s",
|
||||
migrateFlags.version, strings.Join(supportedVersions, ", "))
|
||||
}
|
||||
}
|
||||
|
||||
return NewFileSystemMigrator(fileSystem, yes, dryRun, path, extensions, latestVersions).Run()
|
||||
}
|
||||
|
||||
// ClusterMigrator migrates all the CRs in the cluster for the CRDs matching the label selector.
|
||||
type ClusterMigrator struct {
|
||||
labelSelector client.MatchingLabels
|
||||
kubeClient client.Client
|
||||
}
|
||||
|
||||
// NewMigrator creates a new Migrator instance with the specified label selector.
|
||||
func NewMigrator(kubeClient client.Client, labelSelector client.MatchingLabels) *Migrator {
|
||||
return &Migrator{
|
||||
// NewClusterMigrator creates a new ClusterMigrator instance with the specified label selector.
|
||||
func NewClusterMigrator(kubeClient client.Client, labelSelector client.MatchingLabels) *ClusterMigrator {
|
||||
return &ClusterMigrator{
|
||||
labelSelector: labelSelector,
|
||||
kubeClient: kubeClient,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Migrator) Run(ctx context.Context) error {
|
||||
func (c *ClusterMigrator) Run(ctx context.Context) error {
|
||||
crdList := &apiextensionsv1.CustomResourceDefinitionList{}
|
||||
|
||||
if err := m.kubeClient.List(ctx, crdList, m.labelSelector); err != nil {
|
||||
if err := c.kubeClient.List(ctx, crdList, c.labelSelector); err != nil {
|
||||
return fmt.Errorf("failed to list CRDs: %w", err)
|
||||
}
|
||||
|
||||
for _, crd := range crdList.Items {
|
||||
if err := m.migrateCRD(ctx, crd.Name); err != nil {
|
||||
if err := c.migrateCRD(ctx, crd.Name); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -101,22 +296,22 @@ func (m *Migrator) Run(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Migrator) migrateCRD(ctx context.Context, name string) error {
|
||||
func (c *ClusterMigrator) migrateCRD(ctx context.Context, name string) error {
|
||||
crd := &apiextensionsv1.CustomResourceDefinition{}
|
||||
|
||||
if err := m.kubeClient.Get(ctx, client.ObjectKey{Name: name}, crd); err != nil {
|
||||
if err := c.kubeClient.Get(ctx, client.ObjectKey{Name: name}, crd); err != nil {
|
||||
return fmt.Errorf("failed to get CRD %s: %w", name, err)
|
||||
}
|
||||
|
||||
// get the latest storage version for the CRD
|
||||
storageVersion := m.getStorageVersion(crd)
|
||||
storageVersion := c.getStorageVersion(crd)
|
||||
if storageVersion == "" {
|
||||
return fmt.Errorf("no storage version found for CRD %s", name)
|
||||
}
|
||||
|
||||
// migrate all the resources for the CRD
|
||||
err := retry.RetryOnConflict(retry.DefaultRetry, func() error {
|
||||
return m.migrateCR(ctx, crd, storageVersion)
|
||||
return c.migrateCR(ctx, crd, storageVersion)
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to migrate resources for CRD %s: %w", name, err)
|
||||
@@ -125,7 +320,7 @@ func (m *Migrator) migrateCRD(ctx context.Context, name string) error {
|
||||
// set the CRD status to contain only the latest storage version
|
||||
if len(crd.Status.StoredVersions) > 1 || crd.Status.StoredVersions[0] != storageVersion {
|
||||
crd.Status.StoredVersions = []string{storageVersion}
|
||||
if err := m.kubeClient.Status().Update(ctx, crd); err != nil {
|
||||
if err := c.kubeClient.Status().Update(ctx, crd); err != nil {
|
||||
return fmt.Errorf("failed to update CRD %s status: %w", crd.Name, err)
|
||||
}
|
||||
logger.Successf("%s migrated to storage version %s", crd.Name, storageVersion)
|
||||
@@ -133,8 +328,8 @@ func (m *Migrator) migrateCRD(ctx context.Context, name string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// migrateCR migrates all CRs for the given CRD to the specified version by patching them with an empty patch.
|
||||
func (m *Migrator) migrateCR(ctx context.Context, crd *apiextensionsv1.CustomResourceDefinition, version string) error {
|
||||
// migrateCR migrates all CRs for the given CRD to the specified version by patching them.
|
||||
func (c *ClusterMigrator) migrateCR(ctx context.Context, crd *apiextensionsv1.CustomResourceDefinition, version string) error {
|
||||
list := &unstructured.UnstructuredList{}
|
||||
|
||||
apiVersion := crd.Spec.Group + "/" + version
|
||||
@@ -143,7 +338,7 @@ func (m *Migrator) migrateCR(ctx context.Context, crd *apiextensionsv1.CustomRes
|
||||
list.SetAPIVersion(apiVersion)
|
||||
list.SetKind(listKind)
|
||||
|
||||
err := m.kubeClient.List(ctx, list, client.InNamespace(""))
|
||||
err := c.kubeClient.List(ctx, list, client.InNamespace(""))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list resources for CRD %s: %w", crd.Name, err)
|
||||
}
|
||||
@@ -153,16 +348,39 @@ func (m *Migrator) migrateCR(ctx context.Context, crd *apiextensionsv1.CustomRes
|
||||
}
|
||||
|
||||
for _, item := range list.Items {
|
||||
// patch the resource with an empty patch to update the version
|
||||
if err := m.kubeClient.Patch(
|
||||
ctx,
|
||||
&item,
|
||||
client.RawPatch(client.Merge.Type(), []byte("{}")),
|
||||
); err != nil && !apierrors.IsNotFound(err) {
|
||||
return fmt.Errorf(" %s/%s/%s failed to migrate: %w",
|
||||
patches, err := ssa.PatchMigrateToVersion(&item, apiVersion)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create migration patch for %s/%s/%s: %w",
|
||||
item.GetKind(), item.GetNamespace(), item.GetName(), err)
|
||||
}
|
||||
|
||||
if len(patches) == 0 {
|
||||
// patch the resource with an empty patch to update the version
|
||||
if err := c.kubeClient.Patch(
|
||||
ctx,
|
||||
&item,
|
||||
client.RawPatch(client.Merge.Type(), []byte("{}")),
|
||||
); err != nil && !apierrors.IsNotFound(err) {
|
||||
return fmt.Errorf(" %s/%s/%s failed to migrate: %w",
|
||||
item.GetKind(), item.GetNamespace(), item.GetName(), err)
|
||||
}
|
||||
} else {
|
||||
// patch the resource to migrate the managed fields to the latest apiVersion
|
||||
rawPatch, err := json.Marshal(patches)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal migration patch for %s/%s/%s: %w",
|
||||
item.GetKind(), item.GetNamespace(), item.GetName(), err)
|
||||
}
|
||||
if err := c.kubeClient.Patch(
|
||||
ctx,
|
||||
&item,
|
||||
client.RawPatch(types.JSONPatchType, rawPatch),
|
||||
); err != nil && !apierrors.IsNotFound(err) {
|
||||
return fmt.Errorf(" %s/%s/%s failed to migrate managed fields: %w",
|
||||
item.GetKind(), item.GetNamespace(), item.GetName(), err)
|
||||
}
|
||||
}
|
||||
|
||||
logger.Successf("%s/%s/%s migrated to version %s",
|
||||
item.GetKind(), item.GetNamespace(), item.GetName(), version)
|
||||
}
|
||||
@@ -171,7 +389,7 @@ func (m *Migrator) migrateCR(ctx context.Context, crd *apiextensionsv1.CustomRes
|
||||
}
|
||||
|
||||
// getStorageVersion retrieves the storage version of a CustomResourceDefinition.
|
||||
func (m *Migrator) getStorageVersion(crd *apiextensionsv1.CustomResourceDefinition) string {
|
||||
func (c *ClusterMigrator) getStorageVersion(crd *apiextensionsv1.CustomResourceDefinition) string {
|
||||
var version string
|
||||
for _, v := range crd.Spec.Versions {
|
||||
if v.Storage {
|
||||
@@ -182,3 +400,303 @@ func (m *Migrator) getStorageVersion(crd *apiextensionsv1.CustomResourceDefiniti
|
||||
|
||||
return version
|
||||
}
|
||||
|
||||
// WritableFS extends fs.FS with a WriteFile method.
|
||||
type WritableFS interface {
|
||||
fs.FS
|
||||
WriteFile(name string, data []byte, perm os.FileMode) error
|
||||
}
|
||||
|
||||
// osFS is a WritableFS implementation that uses the file system of the OS.
|
||||
type osFS struct {
|
||||
fs.FS
|
||||
}
|
||||
|
||||
func (o *osFS) WriteFile(name string, data []byte, perm os.FileMode) error {
|
||||
return os.WriteFile(name, data, perm)
|
||||
}
|
||||
|
||||
// FileSystemMigrator migrates all the CRs found in the manifests located in the specified path.
|
||||
type FileSystemMigrator struct {
|
||||
fileSystem WritableFS
|
||||
yes bool
|
||||
dryRun bool
|
||||
path string
|
||||
extensions []string
|
||||
latestVersions map[schema.GroupKind]string
|
||||
}
|
||||
|
||||
// FileAPIUpgrades represents the API upgrades detected in a specific manifest file.
|
||||
type FileAPIUpgrades struct {
|
||||
File string
|
||||
Upgrades []APIUpgrade
|
||||
}
|
||||
|
||||
// APIUpgrade represents an upgrade of a specific API version in a manifest file.
|
||||
type APIUpgrade struct {
|
||||
Line int
|
||||
Kind string
|
||||
OldVersion string
|
||||
NewVersion string
|
||||
}
|
||||
|
||||
// NewFileSystemMigrator creates a new FileSystemMigrator instance with the specified flags.
|
||||
func NewFileSystemMigrator(fileSystem WritableFS, yes, dryRun bool, path string,
|
||||
extensions []string, latestVersions map[schema.GroupKind]string) *FileSystemMigrator {
|
||||
return &FileSystemMigrator{
|
||||
fileSystem: fileSystem,
|
||||
yes: yes,
|
||||
dryRun: dryRun,
|
||||
path: filepath.Clean(path), // convert dir/ to dir to avoid error when walking
|
||||
extensions: extensions,
|
||||
latestVersions: latestVersions,
|
||||
}
|
||||
}
|
||||
|
||||
func (f *FileSystemMigrator) Run() error {
|
||||
logger.Actionf("starting migration of custom resources")
|
||||
|
||||
// List and filter files.
|
||||
files, err := f.listFiles()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Detect upgrades.
|
||||
upgrades, err := f.detectUpgrades(files)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(upgrades) == 0 {
|
||||
logger.Successf("no custom resources found that require migration")
|
||||
return nil
|
||||
}
|
||||
if f.dryRun {
|
||||
logger.Successf("dry-run mode enabled, no changes will be made")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Confirm upgrades.
|
||||
if !f.yes {
|
||||
prompt := promptui.Prompt{
|
||||
Label: "Are you sure you want to proceed with the above upgrades", // Already prints "? [y/N]"
|
||||
IsConfirm: true,
|
||||
}
|
||||
if _, err := prompt.Run(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Migrate files.
|
||||
for _, fileUpgrades := range upgrades {
|
||||
if err := f.migrateFile(&fileUpgrades); err != nil {
|
||||
return err
|
||||
}
|
||||
logger.Successf("file %s migrated successfully", fileUpgrades.File)
|
||||
}
|
||||
|
||||
logger.Successf("custom resources migrated successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *FileSystemMigrator) listFiles() ([]string, error) {
|
||||
fileInfo, err := fs.Stat(f.fileSystem, f.path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to stat path %s: %w", f.path, err)
|
||||
}
|
||||
if fileInfo.IsDir() {
|
||||
return f.listDirectoryFiles()
|
||||
}
|
||||
if err := f.validateSingleFile(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []string{f.path}, nil
|
||||
}
|
||||
|
||||
func (f *FileSystemMigrator) listDirectoryFiles() ([]string, error) {
|
||||
var files []string
|
||||
err := fs.WalkDir(f.fileSystem, f.path, func(path string, dirEntry fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !f.matchesExtensions(path) {
|
||||
return nil
|
||||
}
|
||||
fileInfo, err := dirEntry.Info()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if fileInfo.Mode().IsRegular() {
|
||||
files = append(files, path)
|
||||
} else if !fileInfo.IsDir() {
|
||||
logger.Warningf("skipping irregular file %s", path)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to walk directory %s: %w", f.path, err)
|
||||
}
|
||||
return files, nil
|
||||
}
|
||||
|
||||
func (f *FileSystemMigrator) validateSingleFile() error {
|
||||
if !f.matchesExtensions(f.path) {
|
||||
return fmt.Errorf("file %s does not match the specified extensions: %v",
|
||||
f.path, strings.Join(f.extensions, ", "))
|
||||
}
|
||||
|
||||
// Check if it's irregular by walking the parent directory.
|
||||
var irregular bool
|
||||
err := fs.WalkDir(f.fileSystem, filepath.Dir(f.path), func(path string, dirEntry fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if path != f.path {
|
||||
return nil
|
||||
}
|
||||
fileInfo, err := dirEntry.Info()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !fileInfo.Mode().IsRegular() {
|
||||
irregular = true
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to validate file %s: %w", f.path, err)
|
||||
}
|
||||
if irregular {
|
||||
return fmt.Errorf("file %s is irregular", f.path)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *FileSystemMigrator) matchesExtensions(file string) bool {
|
||||
for _, ext := range f.extensions {
|
||||
if strings.HasSuffix(file, ext) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (f *FileSystemMigrator) detectUpgrades(files []string) ([]FileAPIUpgrades, error) {
|
||||
var upgrades []FileAPIUpgrades
|
||||
for _, file := range files {
|
||||
fileUpgrades, err := f.detectFileUpgrades(file)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(fileUpgrades) == 0 {
|
||||
continue
|
||||
}
|
||||
fu := FileAPIUpgrades{
|
||||
File: file,
|
||||
Upgrades: fileUpgrades,
|
||||
}
|
||||
upgrades = append(upgrades, fu)
|
||||
f.printDetectedUpgrades(&fu)
|
||||
}
|
||||
return upgrades, nil
|
||||
}
|
||||
|
||||
func (f *FileSystemMigrator) detectFileUpgrades(file string) ([]APIUpgrade, error) {
|
||||
b, err := fs.ReadFile(f.fileSystem, file)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read file %s: %w", file, err)
|
||||
}
|
||||
lines := strings.Split(string(b), "\n")
|
||||
|
||||
var fileUpgrades []APIUpgrade
|
||||
for line, apiVersionLine := range lines {
|
||||
// Parse apiVersion.
|
||||
const apiVersionPrefix = "apiVersion: "
|
||||
idx := strings.Index(apiVersionLine, apiVersionPrefix)
|
||||
if idx == -1 {
|
||||
continue
|
||||
}
|
||||
apiVersionValuePrefix := strings.TrimSpace(apiVersionLine[idx+len(apiVersionPrefix):])
|
||||
apiVersion := strings.Split(apiVersionValuePrefix, " ")[0]
|
||||
gv, err := schema.ParseGroupVersion(apiVersion)
|
||||
if err != nil {
|
||||
logger.Warningf("%s:%d: %v", file, line+1, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse kind.
|
||||
if line+1 >= len(lines) {
|
||||
continue
|
||||
}
|
||||
kindLine := lines[line+1]
|
||||
const kindPrefix = "kind: "
|
||||
idx = strings.Index(kindLine, kindPrefix)
|
||||
if idx == -1 {
|
||||
continue
|
||||
}
|
||||
kindValuePrefix := strings.TrimSpace(kindLine[idx+len(kindPrefix):])
|
||||
kind := strings.Split(kindValuePrefix, " ")[0]
|
||||
|
||||
// Build GroupKind.
|
||||
gk := schema.GroupKind{
|
||||
Group: gv.Group,
|
||||
Kind: kind,
|
||||
}
|
||||
|
||||
// Check if there's a newer version for the GroupKind.
|
||||
latestVersion, ok := f.latestVersions[gk]
|
||||
if !ok || latestVersion == gv.Version {
|
||||
continue
|
||||
}
|
||||
|
||||
// Record the upgrade.
|
||||
fileUpgrades = append(fileUpgrades, APIUpgrade{
|
||||
Line: line,
|
||||
Kind: kind,
|
||||
OldVersion: gv.Version,
|
||||
NewVersion: latestVersion,
|
||||
})
|
||||
}
|
||||
return fileUpgrades, nil
|
||||
}
|
||||
|
||||
func (f *FileSystemMigrator) printDetectedUpgrades(fileUpgrades *FileAPIUpgrades) {
|
||||
for _, upgrade := range fileUpgrades.Upgrades {
|
||||
logger.Generatef("%s:%d: %s %s -> %s",
|
||||
fileUpgrades.File,
|
||||
upgrade.Line+1,
|
||||
upgrade.Kind,
|
||||
upgrade.OldVersion,
|
||||
upgrade.NewVersion)
|
||||
}
|
||||
}
|
||||
|
||||
func (f *FileSystemMigrator) migrateFile(fileUpgrades *FileAPIUpgrades) error {
|
||||
// Read file and map lines.
|
||||
b, err := fs.ReadFile(f.fileSystem, fileUpgrades.File)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read file %s: %w", fileUpgrades.File, err)
|
||||
}
|
||||
lines := strings.Split(string(b), "\n")
|
||||
|
||||
// Apply upgrades to lines.
|
||||
for _, upgrade := range fileUpgrades.Upgrades {
|
||||
line := lines[upgrade.Line]
|
||||
line = strings.Replace(line, upgrade.OldVersion, upgrade.NewVersion, 1)
|
||||
lines[upgrade.Line] = line
|
||||
}
|
||||
|
||||
// Read file info to preserve permissions.
|
||||
fileInfo, err := fs.Stat(f.fileSystem, fileUpgrades.File)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to stat file %s: %w", fileUpgrades.File, err)
|
||||
}
|
||||
|
||||
// Write file with preserved permissions.
|
||||
b = []byte(strings.Join(lines, "\n"))
|
||||
if err := f.fileSystem.WriteFile(fileUpgrades.File, b, fileInfo.Mode()); err != nil {
|
||||
return fmt.Errorf("failed to write file %s: %w", fileUpgrades.File, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
/*
|
||||
Copyright 2025 The Flux authors
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io/fs"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
. "github.com/onsi/gomega"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
)
|
||||
|
||||
type writeToMemoryFS struct {
|
||||
fs.FS
|
||||
|
||||
writtenFiles map[string][]byte
|
||||
}
|
||||
|
||||
func (m *writeToMemoryFS) WriteFile(name string, data []byte, perm os.FileMode) error {
|
||||
m.writtenFiles[name] = data
|
||||
return nil
|
||||
}
|
||||
|
||||
type writtenFile struct {
|
||||
file string
|
||||
goldenFile string
|
||||
}
|
||||
|
||||
func TestFileSystemMigrator(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
path string
|
||||
outputGolden string
|
||||
writtenFiles []writtenFile
|
||||
err string
|
||||
}{
|
||||
{
|
||||
name: "errors out for single file that is a symlink",
|
||||
path: "testdata/migrate/file-system/single-file-link.yaml",
|
||||
err: "file testdata/migrate/file-system/single-file-link.yaml is irregular",
|
||||
},
|
||||
{
|
||||
name: "errors out for single file with wrong extension",
|
||||
path: "testdata/migrate/file-system/single-file-wrong-ext.json",
|
||||
err: "file testdata/migrate/file-system/single-file-wrong-ext.json does not match the specified extensions: .yaml, .yml",
|
||||
},
|
||||
{
|
||||
name: "migrate single file",
|
||||
path: "testdata/migrate/file-system/single-file.yaml",
|
||||
outputGolden: "testdata/migrate/file-system/single-file.yaml.output.golden",
|
||||
writtenFiles: []writtenFile{
|
||||
{
|
||||
file: "testdata/migrate/file-system/single-file.yaml",
|
||||
goldenFile: "testdata/migrate/file-system/single-file.yaml.golden",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "migrate files in directory",
|
||||
path: "testdata/migrate/file-system/dir",
|
||||
outputGolden: "testdata/migrate/file-system/dir.output.golden",
|
||||
writtenFiles: []writtenFile{
|
||||
{
|
||||
file: "testdata/migrate/file-system/dir/some-dir/another-file.yaml",
|
||||
goldenFile: "testdata/migrate/file-system/dir.golden/some-dir/another-file.yaml",
|
||||
},
|
||||
{
|
||||
file: "testdata/migrate/file-system/dir/some-dir/another-file.yml",
|
||||
goldenFile: "testdata/migrate/file-system/dir.golden/some-dir/another-file.yml",
|
||||
},
|
||||
{
|
||||
file: "testdata/migrate/file-system/dir/some-file.yaml",
|
||||
goldenFile: "testdata/migrate/file-system/dir.golden/some-file.yaml",
|
||||
},
|
||||
{
|
||||
file: "testdata/migrate/file-system/dir/some-file.yml",
|
||||
goldenFile: "testdata/migrate/file-system/dir.golden/some-file.yml",
|
||||
},
|
||||
},
|
||||
},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
g := NewWithT(t)
|
||||
|
||||
// Store logger, replace with test logger, and restore at the end of the test.
|
||||
var testLogger bytes.Buffer
|
||||
oldLogger := logger
|
||||
logger = stderrLogger{&testLogger}
|
||||
t.Cleanup(func() { logger = oldLogger })
|
||||
|
||||
// Open current working directory as root and build write-to-memory filesystem.
|
||||
pathRoot, err := os.OpenRoot(".")
|
||||
g.Expect(err).ToNot(HaveOccurred())
|
||||
t.Cleanup(func() { pathRoot.Close() })
|
||||
fileSystem := &writeToMemoryFS{
|
||||
FS: pathRoot.FS(),
|
||||
writtenFiles: make(map[string][]byte),
|
||||
}
|
||||
|
||||
// Prepare other inputs.
|
||||
const yes = true
|
||||
const dryRun = false
|
||||
extensions := []string{".yaml", ".yml"}
|
||||
latestVersions := map[schema.GroupKind]string{
|
||||
{Group: "image.toolkit.fluxcd.io", Kind: "ImageRepository"}: "v1",
|
||||
{Group: "image.toolkit.fluxcd.io", Kind: "ImagePolicy"}: "v1",
|
||||
{Group: "image.toolkit.fluxcd.io", Kind: "ImageUpdateAutomation"}: "v1",
|
||||
}
|
||||
|
||||
// Run migration.
|
||||
err = NewFileSystemMigrator(fileSystem, yes, dryRun, tt.path, extensions, latestVersions).Run()
|
||||
if tt.err != "" {
|
||||
g.Expect(err).To(HaveOccurred())
|
||||
g.Expect(err.Error()).To(Equal(tt.err))
|
||||
return
|
||||
}
|
||||
g.Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Assert logger output.
|
||||
b, err := os.ReadFile(tt.outputGolden)
|
||||
g.Expect(err).ToNot(HaveOccurred())
|
||||
g.Expect(string(b)).To(Equal(testLogger.String()),
|
||||
"logger output does not match golden file %s", tt.outputGolden)
|
||||
|
||||
// Assert which files were written.
|
||||
writtenFiles := make([]string, 0, len(fileSystem.writtenFiles))
|
||||
for name := range fileSystem.writtenFiles {
|
||||
writtenFiles = append(writtenFiles, name)
|
||||
}
|
||||
expectedWrittenFiles := make([]string, 0, len(tt.writtenFiles))
|
||||
for _, wf := range tt.writtenFiles {
|
||||
expectedWrittenFiles = append(expectedWrittenFiles, wf.file)
|
||||
}
|
||||
g.Expect(writtenFiles).To(ConsistOf(expectedWrittenFiles))
|
||||
|
||||
// Assert contents of written files.
|
||||
for _, wf := range tt.writtenFiles {
|
||||
b, err := os.ReadFile(wf.goldenFile)
|
||||
g.Expect(err).ToNot(HaveOccurred())
|
||||
g.Expect(string(fileSystem.writtenFiles[wf.file])).To(Equal(string(b)),
|
||||
"file %s does not match golden file %s", wf.file, wf.goldenFile)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+4
-3
@@ -20,6 +20,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/go-containerregistry/pkg/authn"
|
||||
"github.com/google/go-containerregistry/pkg/crane"
|
||||
|
||||
"github.com/fluxcd/pkg/auth"
|
||||
@@ -28,14 +29,14 @@ import (
|
||||
)
|
||||
|
||||
// loginWithProvider gets a crane authentication option for the given provider and URL.
|
||||
func loginWithProvider(ctx context.Context, url, provider string) (crane.Option, error) {
|
||||
func loginWithProvider(ctx context.Context, url, provider string) (crane.Option, authn.Authenticator, error) {
|
||||
var opts []auth.Option
|
||||
if provider == azure.ProviderName {
|
||||
opts = append(opts, auth.WithAllowShellOut())
|
||||
}
|
||||
authenticator, err := authutils.GetArtifactRegistryCredentials(ctx, provider, url, opts...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not login to provider %s with url %s: %w", provider, url, err)
|
||||
return nil, nil, fmt.Errorf("could not login to provider %s with url %s: %w", provider, url, err)
|
||||
}
|
||||
return crane.WithAuth(authenticator), nil
|
||||
return crane.WithAuth(authenticator), authenticator, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
/*
|
||||
Copyright 2026 The Flux authors
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/briandowns/spinner"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/fluxcd/flux2/v2/internal/plugin"
|
||||
)
|
||||
|
||||
var pluginHandler = plugin.NewHandler()
|
||||
|
||||
var pluginCmd = &cobra.Command{
|
||||
Use: "plugin",
|
||||
Short: "Manage Flux CLI plugins",
|
||||
Long: `The plugin sub-commands manage Flux CLI plugins.`,
|
||||
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
|
||||
// No-op: skip root's namespace DNS validation for plugin commands.
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(pluginCmd)
|
||||
}
|
||||
|
||||
// builtinCommandNames returns the names of all non-plugin commands on rootCmd.
|
||||
func builtinCommandNames() []string {
|
||||
var names []string
|
||||
for _, c := range rootCmd.Commands() {
|
||||
if c.GroupID != "plugin" {
|
||||
names = append(names, c.Name())
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// registerPlugins scans the plugin directory and registers discovered
|
||||
// plugins as Cobra subcommands on rootCmd.
|
||||
func registerPlugins() {
|
||||
plugins := pluginHandler.Discover(builtinCommandNames())
|
||||
if len(plugins) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
if !rootCmd.ContainsGroup("plugin") {
|
||||
rootCmd.AddGroup(&cobra.Group{
|
||||
ID: "plugin",
|
||||
Title: "Plugin Commands:",
|
||||
})
|
||||
}
|
||||
|
||||
for _, p := range plugins {
|
||||
cmd := &cobra.Command{
|
||||
Use: p.Name,
|
||||
Short: fmt.Sprintf("Runs the %s plugin", p.Name),
|
||||
Long: fmt.Sprintf("This command runs the %s plugin.\nUse 'flux %s --help' for full plugin help.", p.Name, p.Name),
|
||||
DisableFlagParsing: true,
|
||||
GroupID: "plugin",
|
||||
ValidArgsFunction: plugin.CompleteFunc(p.Path),
|
||||
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
|
||||
return nil
|
||||
},
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return plugin.Exec(p.Path, args)
|
||||
},
|
||||
}
|
||||
rootCmd.AddCommand(cmd)
|
||||
}
|
||||
}
|
||||
|
||||
// parseNameVersion splits "operator@0.45.0" into ("operator", "0.45.0").
|
||||
// If no @ is present, version is empty (latest).
|
||||
func parseNameVersion(s string) (string, string) {
|
||||
name, version, found := strings.Cut(s, "@")
|
||||
if found {
|
||||
return name, version
|
||||
}
|
||||
return s, ""
|
||||
}
|
||||
|
||||
// isDigestRef reports whether ref is a content-addressable digest
|
||||
// (e.g. "sha256:06e0a38...").
|
||||
func isDigestRef(ref string) bool {
|
||||
return strings.HasPrefix(ref, "sha256:")
|
||||
}
|
||||
|
||||
// newCatalogClient creates a CatalogClient that respects FLUXCD_PLUGIN_CATALOG.
|
||||
func newCatalogClient() *plugin.CatalogClient {
|
||||
client := plugin.NewCatalogClient()
|
||||
client.GetEnv = pluginHandler.GetEnv
|
||||
return client
|
||||
}
|
||||
|
||||
func newPluginSpinner(message string) *spinner.Spinner {
|
||||
s := spinner.New(spinner.CharSets[14], 100*time.Millisecond)
|
||||
s.Suffix = " " + message
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/*
|
||||
Copyright 2026 The Flux authors
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/fluxcd/flux2/v2/internal/plugin"
|
||||
plugintypes "github.com/fluxcd/flux2/v2/pkg/plugin"
|
||||
)
|
||||
|
||||
var pluginInstallCmd = &cobra.Command{
|
||||
Use: "install <name>[@<version>|@<digest>]",
|
||||
Short: "Install a plugin from the catalog",
|
||||
Long: `The plugin install command downloads and installs a plugin from the Flux plugin catalog.
|
||||
|
||||
Examples:
|
||||
# Install the latest version
|
||||
flux plugin install operator
|
||||
|
||||
# Install a specific version
|
||||
flux plugin install operator@0.45.0
|
||||
|
||||
# Install pinned to a specific digest
|
||||
flux plugin install operator@sha256:06e0a38db4fa6bc9f705a577c7e58dc020bfe2618e45488599e5ef7bb62e3a8a`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: pluginInstallCmdRun,
|
||||
}
|
||||
|
||||
func init() {
|
||||
pluginCmd.AddCommand(pluginInstallCmd)
|
||||
}
|
||||
|
||||
func pluginInstallCmdRun(cmd *cobra.Command, args []string) error {
|
||||
nameVersion := args[0]
|
||||
name, ref := parseNameVersion(nameVersion)
|
||||
|
||||
catalogClient := newCatalogClient()
|
||||
manifest, err := catalogClient.FetchManifest(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var pv *plugintypes.Version
|
||||
var plat *plugintypes.Platform
|
||||
|
||||
if isDigestRef(ref) {
|
||||
dm, err := plugin.ResolveByDigest(manifest, ref, runtime.GOOS, runtime.GOARCH)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pv = dm.Version
|
||||
plat = dm.Platform
|
||||
} else {
|
||||
pv, err = plugin.ResolveVersion(manifest, ref)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
plat, err = plugin.ResolvePlatform(pv, runtime.GOOS, runtime.GOARCH)
|
||||
if err != nil {
|
||||
return fmt.Errorf("plugin %q v%s has no binary for %s/%s", name, pv.Version, runtime.GOOS, runtime.GOARCH)
|
||||
}
|
||||
}
|
||||
|
||||
pluginDir := pluginHandler.EnsurePluginDir()
|
||||
|
||||
installer := plugin.NewInstaller()
|
||||
sp := newPluginSpinner(fmt.Sprintf("installing %s v%s", name, pv.Version))
|
||||
sp.Start()
|
||||
if err := installer.Install(pluginDir, manifest, pv, plat); err != nil {
|
||||
sp.Stop()
|
||||
return err
|
||||
}
|
||||
sp.Stop()
|
||||
|
||||
logger.Successf("installed %s v%s", name, pv.Version)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/*
|
||||
Copyright 2026 The Flux authors
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/fluxcd/flux2/v2/internal/plugin"
|
||||
"github.com/fluxcd/flux2/v2/pkg/printers"
|
||||
)
|
||||
|
||||
var pluginListCmd = &cobra.Command{
|
||||
Use: "list",
|
||||
Aliases: []string{"ls"},
|
||||
Short: "List installed plugins",
|
||||
Long: `The plugin list command shows all installed plugins with their versions and paths.`,
|
||||
RunE: pluginListCmdRun,
|
||||
}
|
||||
|
||||
func init() {
|
||||
pluginCmd.AddCommand(pluginListCmd)
|
||||
}
|
||||
|
||||
func pluginListCmdRun(cmd *cobra.Command, args []string) error {
|
||||
pluginDir := pluginHandler.PluginDir()
|
||||
plugins := pluginHandler.Discover(builtinCommandNames())
|
||||
if len(plugins) == 0 {
|
||||
cmd.Println("No plugins found")
|
||||
return nil
|
||||
}
|
||||
|
||||
header := []string{"NAME", "VERSION", "PATH"}
|
||||
var rows [][]string
|
||||
for _, p := range plugins {
|
||||
version := "manual"
|
||||
if receipt := plugin.ReadReceipt(pluginDir, p.Name); receipt != nil {
|
||||
version = receipt.Version
|
||||
}
|
||||
rows = append(rows, []string{p.Name, version, p.Path})
|
||||
}
|
||||
|
||||
return printers.TablePrinter(header).Print(cmd.OutOrStdout(), rows)
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/*
|
||||
Copyright 2026 The Flux authors
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/fluxcd/flux2/v2/internal/plugin"
|
||||
"github.com/fluxcd/flux2/v2/pkg/printers"
|
||||
)
|
||||
|
||||
var pluginSearchCmd = &cobra.Command{
|
||||
Use: "search [query]",
|
||||
Short: "Search the plugin catalog",
|
||||
Long: `The plugin search command lists available plugins from the Flux plugin catalog.`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: pluginSearchCmdRun,
|
||||
}
|
||||
|
||||
func init() {
|
||||
pluginCmd.AddCommand(pluginSearchCmd)
|
||||
}
|
||||
|
||||
func pluginSearchCmdRun(cmd *cobra.Command, args []string) error {
|
||||
catalogClient := newCatalogClient()
|
||||
catalog, err := catalogClient.FetchCatalog()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var query string
|
||||
if len(args) == 1 {
|
||||
query = strings.ToLower(args[0])
|
||||
}
|
||||
|
||||
pluginDir := pluginHandler.PluginDir()
|
||||
header := []string{"NAME", "DESCRIPTION", "INSTALLED"}
|
||||
var rows [][]string
|
||||
for _, entry := range catalog.Plugins {
|
||||
if query != "" {
|
||||
if !strings.Contains(strings.ToLower(entry.Name), query) &&
|
||||
!strings.Contains(strings.ToLower(entry.Description), query) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
installed := ""
|
||||
if receipt := plugin.ReadReceipt(pluginDir, entry.Name); receipt != nil {
|
||||
installed = receipt.Version
|
||||
}
|
||||
|
||||
rows = append(rows, []string{entry.Name, entry.Description, installed})
|
||||
}
|
||||
|
||||
if len(rows) == 0 {
|
||||
if query != "" {
|
||||
cmd.Printf("No plugins matching %q found in catalog\n", query)
|
||||
} else {
|
||||
cmd.Println("No plugins found in catalog")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
return printers.TablePrinter(header).Print(cmd.OutOrStdout(), rows)
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
/*
|
||||
Copyright 2026 The Flux authors
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/fluxcd/flux2/v2/internal/plugin"
|
||||
)
|
||||
|
||||
func TestPluginAppearsInHelp(t *testing.T) {
|
||||
origHandler := pluginHandler
|
||||
defer func() { pluginHandler = origHandler }()
|
||||
|
||||
pluginDir := t.TempDir()
|
||||
|
||||
fakeBin := pluginDir + "/flux-testplugin"
|
||||
os.WriteFile(fakeBin, []byte("#!/bin/sh\necho test"), 0o755)
|
||||
|
||||
pluginHandler = &plugin.Handler{
|
||||
ReadDir: os.ReadDir,
|
||||
Stat: os.Stat,
|
||||
GetEnv: func(key string) string {
|
||||
if key == "FLUXCD_PLUGINS" {
|
||||
return pluginDir
|
||||
}
|
||||
return ""
|
||||
},
|
||||
HomeDir: func() (string, error) { return t.TempDir(), nil },
|
||||
}
|
||||
|
||||
registerPlugins()
|
||||
defer func() {
|
||||
cmds := rootCmd.Commands()
|
||||
for _, cmd := range cmds {
|
||||
if cmd.Name() == "testplugin" {
|
||||
rootCmd.RemoveCommand(cmd)
|
||||
break
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
output, err := executeCommand("--help")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(output, "Plugin Commands:") {
|
||||
t.Error("expected 'Plugin Commands:' in help output")
|
||||
}
|
||||
if !strings.Contains(output, "testplugin") {
|
||||
t.Error("expected 'testplugin' in help output")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginListOutput(t *testing.T) {
|
||||
origHandler := pluginHandler
|
||||
defer func() { pluginHandler = origHandler }()
|
||||
|
||||
pluginDir := t.TempDir()
|
||||
|
||||
fakeBin := pluginDir + "/flux-myplugin"
|
||||
os.WriteFile(fakeBin, []byte("#!/bin/sh\necho test"), 0o755)
|
||||
|
||||
pluginHandler = &plugin.Handler{
|
||||
ReadDir: os.ReadDir,
|
||||
Stat: os.Stat,
|
||||
GetEnv: func(key string) string {
|
||||
if key == "FLUXCD_PLUGINS" {
|
||||
return pluginDir
|
||||
}
|
||||
return ""
|
||||
},
|
||||
HomeDir: func() (string, error) { return t.TempDir(), nil },
|
||||
}
|
||||
|
||||
output, err := executeCommand("plugin list")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(output, "myplugin") {
|
||||
t.Errorf("expected 'myplugin' in output, got: %s", output)
|
||||
}
|
||||
if !strings.Contains(output, "manual") {
|
||||
t.Errorf("expected 'manual' in output (no receipt), got: %s", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginListWithReceipt(t *testing.T) {
|
||||
origHandler := pluginHandler
|
||||
defer func() { pluginHandler = origHandler }()
|
||||
|
||||
pluginDir := t.TempDir()
|
||||
|
||||
fakeBin := pluginDir + "/flux-myplugin"
|
||||
os.WriteFile(fakeBin, []byte("#!/bin/sh\necho test"), 0o755)
|
||||
receipt := pluginDir + "/flux-myplugin.yaml"
|
||||
os.WriteFile(receipt, []byte("name: myplugin\nversion: \"1.2.3\"\n"), 0o644)
|
||||
|
||||
pluginHandler = &plugin.Handler{
|
||||
ReadDir: os.ReadDir,
|
||||
Stat: os.Stat,
|
||||
GetEnv: func(key string) string {
|
||||
if key == "FLUXCD_PLUGINS" {
|
||||
return pluginDir
|
||||
}
|
||||
return ""
|
||||
},
|
||||
HomeDir: func() (string, error) { return t.TempDir(), nil },
|
||||
}
|
||||
|
||||
output, err := executeCommand("plugin list")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(output, "1.2.3") {
|
||||
t.Errorf("expected version '1.2.3' in output, got: %s", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginListEmpty(t *testing.T) {
|
||||
origHandler := pluginHandler
|
||||
defer func() { pluginHandler = origHandler }()
|
||||
|
||||
pluginDir := t.TempDir()
|
||||
|
||||
pluginHandler = &plugin.Handler{
|
||||
ReadDir: os.ReadDir,
|
||||
Stat: os.Stat,
|
||||
GetEnv: func(key string) string {
|
||||
if key == "FLUXCD_PLUGINS" {
|
||||
return pluginDir
|
||||
}
|
||||
return ""
|
||||
},
|
||||
HomeDir: func() (string, error) { return t.TempDir(), nil },
|
||||
}
|
||||
|
||||
output, err := executeCommand("plugin list")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(output, "No plugins found") {
|
||||
t.Errorf("expected 'No plugins found', got: %s", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoPluginsNoRegistration(t *testing.T) {
|
||||
origHandler := pluginHandler
|
||||
defer func() { pluginHandler = origHandler }()
|
||||
|
||||
pluginHandler = &plugin.Handler{
|
||||
ReadDir: func(name string) ([]os.DirEntry, error) {
|
||||
return nil, fmt.Errorf("no dir")
|
||||
},
|
||||
Stat: os.Stat,
|
||||
GetEnv: func(key string) string {
|
||||
if key == "FLUXCD_PLUGINS" {
|
||||
return "/nonexistent"
|
||||
}
|
||||
return ""
|
||||
},
|
||||
HomeDir: func() (string, error) { return t.TempDir(), nil },
|
||||
}
|
||||
|
||||
// Verify that registerPlugins with no plugins doesn't add any commands.
|
||||
before := len(rootCmd.Commands())
|
||||
registerPlugins()
|
||||
after := len(rootCmd.Commands())
|
||||
if after != before {
|
||||
t.Errorf("expected no new commands, got %d new", after-before)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginSkipsPersistentPreRun(t *testing.T) {
|
||||
// Plugin commands override root's PersistentPreRunE with a no-op,
|
||||
// so an invalid namespace should not trigger a validation error.
|
||||
_, err := executeCommand("plugin list")
|
||||
if err != nil {
|
||||
t.Fatalf("plugin list should not trigger root's namespace validation: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseNameVersion(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
wantName string
|
||||
wantVersion string
|
||||
}{
|
||||
{"operator", "operator", ""},
|
||||
{"operator@0.45.0", "operator", "0.45.0"},
|
||||
{"my-tool@1.0.0", "my-tool", "1.0.0"},
|
||||
{"plugin@", "plugin", ""},
|
||||
{"operator@sha256:abc123", "operator", "sha256:abc123"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
name, version := parseNameVersion(tt.input)
|
||||
if name != tt.wantName {
|
||||
t.Errorf("name: got %q, want %q", name, tt.wantName)
|
||||
}
|
||||
if version != tt.wantVersion {
|
||||
t.Errorf("version: got %q, want %q", version, tt.wantVersion)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsDigestRef(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want bool
|
||||
}{
|
||||
{"sha256:06e0a38db4fa6bc9f705a577c7e58dc020bfe2618e45488599e5ef7bb62e3a8a", true},
|
||||
{"0.45.0", false},
|
||||
{"", false},
|
||||
{"sha256", false},
|
||||
{"SHA256:abc", false}, // case-sensitive
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
if got := isDigestRef(tt.input); got != tt.want {
|
||||
t.Errorf("isDigestRef(%q) = %v, want %v", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginDiscoverSkipsBuiltins(t *testing.T) {
|
||||
origHandler := pluginHandler
|
||||
defer func() { pluginHandler = origHandler }()
|
||||
|
||||
pluginDir := t.TempDir()
|
||||
|
||||
for _, name := range []string{"flux-get", "flux-create", "flux-version"} {
|
||||
os.WriteFile(pluginDir+"/"+name, []byte("#!/bin/sh"), 0o755)
|
||||
}
|
||||
os.WriteFile(pluginDir+"/flux-myplugin", []byte("#!/bin/sh"), 0o755)
|
||||
|
||||
pluginHandler = &plugin.Handler{
|
||||
ReadDir: os.ReadDir,
|
||||
Stat: os.Stat,
|
||||
GetEnv: func(key string) string {
|
||||
if key == "FLUXCD_PLUGINS" {
|
||||
return pluginDir
|
||||
}
|
||||
return ""
|
||||
},
|
||||
HomeDir: func() (string, error) { return t.TempDir(), nil },
|
||||
}
|
||||
|
||||
plugins := pluginHandler.Discover(builtinCommandNames())
|
||||
|
||||
if len(plugins) != 1 {
|
||||
names := make([]string, len(plugins))
|
||||
for i, p := range plugins {
|
||||
names[i] = p.Name
|
||||
}
|
||||
t.Fatalf("expected 1 plugin, got %d: %v", len(plugins), names)
|
||||
}
|
||||
if plugins[0].Name != "myplugin" {
|
||||
t.Errorf("expected 'myplugin', got %q", plugins[0].Name)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
Copyright 2026 The Flux authors
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/fluxcd/flux2/v2/internal/plugin"
|
||||
)
|
||||
|
||||
var pluginUninstallCmd = &cobra.Command{
|
||||
Use: "uninstall <name>",
|
||||
Aliases: []string{"delete"},
|
||||
Short: "Uninstall a plugin",
|
||||
Long: `The plugin uninstall command removes a plugin binary and its receipt from the plugin directory.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: pluginUninstallCmdRun,
|
||||
}
|
||||
|
||||
func init() {
|
||||
pluginCmd.AddCommand(pluginUninstallCmd)
|
||||
}
|
||||
|
||||
func pluginUninstallCmdRun(cmd *cobra.Command, args []string) error {
|
||||
name := args[0]
|
||||
pluginDir := pluginHandler.PluginDir()
|
||||
|
||||
if err := plugin.Uninstall(pluginDir, name); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logger.Successf("uninstalled %s", name)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
/*
|
||||
Copyright 2026 The Flux authors
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/fluxcd/flux2/v2/internal/plugin"
|
||||
)
|
||||
|
||||
var pluginUpdateCmd = &cobra.Command{
|
||||
Use: "update [name]",
|
||||
Aliases: []string{"upgrade"},
|
||||
Short: "Update installed plugins",
|
||||
Long: `The plugin update command updates installed plugins to their latest versions.
|
||||
|
||||
Examples:
|
||||
# Update a single plugin
|
||||
flux plugin update operator
|
||||
|
||||
# Update all installed plugins
|
||||
flux plugin update`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: pluginUpdateCmdRun,
|
||||
}
|
||||
|
||||
func init() {
|
||||
pluginCmd.AddCommand(pluginUpdateCmd)
|
||||
}
|
||||
|
||||
func pluginUpdateCmdRun(cmd *cobra.Command, args []string) error {
|
||||
catalogClient := newCatalogClient()
|
||||
|
||||
plugins := pluginHandler.Discover(builtinCommandNames())
|
||||
if len(plugins) == 0 {
|
||||
cmd.Println("No plugins found")
|
||||
return nil
|
||||
}
|
||||
|
||||
// If a specific plugin is requested, filter to just that one.
|
||||
if len(args) == 1 {
|
||||
name := args[0]
|
||||
var found bool
|
||||
for _, p := range plugins {
|
||||
if p.Name == name {
|
||||
plugins = []plugin.Plugin{p}
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return fmt.Errorf("plugin %q is not installed", name)
|
||||
}
|
||||
}
|
||||
|
||||
pluginDir := pluginHandler.EnsurePluginDir()
|
||||
installer := plugin.NewInstaller()
|
||||
for _, p := range plugins {
|
||||
result := plugin.CheckUpdate(pluginDir, p.Name, catalogClient, runtime.GOOS, runtime.GOARCH)
|
||||
if result.Err != nil {
|
||||
logger.Failuref("error checking %s: %v", p.Name, result.Err)
|
||||
continue
|
||||
}
|
||||
if result.Skipped {
|
||||
if result.SkipReason == plugin.SkipReasonManual {
|
||||
logger.Warningf("skipping %s (%s)", p.Name, result.SkipReason)
|
||||
} else {
|
||||
logger.Successf("%s already up to date (v%s)", p.Name, result.FromVersion)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
sp := newPluginSpinner(fmt.Sprintf("updating %s v%s → v%s", p.Name, result.FromVersion, result.ToVersion))
|
||||
sp.Start()
|
||||
if err := installer.Install(pluginDir, result.Manifest, result.Version, result.Platform); err != nil {
|
||||
sp.Stop()
|
||||
logger.Failuref("error updating %s: %v", p.Name, err)
|
||||
continue
|
||||
}
|
||||
sp.Stop()
|
||||
logger.Successf("updated %s v%s → v%s", p.Name, result.FromVersion, result.ToVersion)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -94,7 +94,7 @@ func pullArtifactCmdRun(cmd *cobra.Command, args []string) error {
|
||||
|
||||
if pullArtifactArgs.provider.String() != sourcev1.GenericOCIProvider {
|
||||
logger.Actionf("logging in to registry with provider credentials")
|
||||
opt, err := loginWithProvider(ctx, url, pullArtifactArgs.provider.String())
|
||||
opt, _, err := loginWithProvider(ctx, url, pullArtifactArgs.provider.String())
|
||||
if err != nil {
|
||||
return fmt.Errorf("error during login with provider: %w", err)
|
||||
}
|
||||
|
||||
+25
-13
@@ -103,17 +103,18 @@ The command can read the credentials from '~/.docker/config.json' but they can a
|
||||
}
|
||||
|
||||
type pushArtifactFlags struct {
|
||||
path string
|
||||
source string
|
||||
revision string
|
||||
creds string
|
||||
provider flags.SourceOCIProvider
|
||||
ignorePaths []string
|
||||
annotations []string
|
||||
output string
|
||||
debug bool
|
||||
reproducible bool
|
||||
insecure bool
|
||||
path string
|
||||
source string
|
||||
revision string
|
||||
creds string
|
||||
provider flags.SourceOCIProvider
|
||||
ignorePaths []string
|
||||
annotations []string
|
||||
output string
|
||||
debug bool
|
||||
reproducible bool
|
||||
insecure bool
|
||||
resolveSymlinks bool
|
||||
}
|
||||
|
||||
var pushArtifactArgs = newPushArtifactFlags()
|
||||
@@ -137,6 +138,7 @@ func init() {
|
||||
pushArtifactCmd.Flags().BoolVarP(&pushArtifactArgs.debug, "debug", "", false, "display logs from underlying library")
|
||||
pushArtifactCmd.Flags().BoolVar(&pushArtifactArgs.reproducible, "reproducible", false, "ensure reproducible image digests by setting the created timestamp to '1970-01-01T00:00:00Z'")
|
||||
pushArtifactCmd.Flags().BoolVar(&pushArtifactArgs.insecure, "insecure-registry", false, "allows artifacts to be pushed without TLS")
|
||||
pushArtifactCmd.Flags().BoolVar(&pushArtifactArgs.resolveSymlinks, "resolve-symlinks", false, "resolve symlinks by copying their targets into the artifact")
|
||||
|
||||
pushCmd.AddCommand(pushArtifactCmd)
|
||||
}
|
||||
@@ -183,6 +185,15 @@ func pushArtifactCmdRun(cmd *cobra.Command, args []string) error {
|
||||
return fmt.Errorf("invalid path '%s', must point to an existing directory or file: %w", path, err)
|
||||
}
|
||||
|
||||
if pushArtifactArgs.resolveSymlinks {
|
||||
resolved, cleanupDir, err := resolveSymlinks(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolving symlinks failed: %w", err)
|
||||
}
|
||||
defer os.RemoveAll(cleanupDir)
|
||||
path = resolved
|
||||
}
|
||||
|
||||
annotations := map[string]string{}
|
||||
for _, annotation := range pushArtifactArgs.annotations {
|
||||
kv := strings.Split(annotation, "=")
|
||||
@@ -225,11 +236,12 @@ func pushArtifactCmdRun(cmd *cobra.Command, args []string) error {
|
||||
|
||||
if provider := pushArtifactArgs.provider.String(); provider != sourcev1.GenericOCIProvider {
|
||||
logger.Actionf("logging in to registry with provider credentials")
|
||||
authOpt, err := loginWithProvider(ctx, url, provider)
|
||||
var opt crane.Option
|
||||
opt, authenticator, err = loginWithProvider(ctx, url, provider)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error during login with provider: %w", err)
|
||||
}
|
||||
opts = append(opts, authOpt)
|
||||
opts = append(opts, opt)
|
||||
}
|
||||
|
||||
if rootArgs.timeout != 0 {
|
||||
|
||||
@@ -152,7 +152,14 @@ func reconciliationHandled(kubeClient client.Client, namespacedName types.Namesp
|
||||
return false, err
|
||||
}
|
||||
|
||||
return result.Status == kstatus.CurrentStatus, nil
|
||||
switch result.Status {
|
||||
case kstatus.CurrentStatus:
|
||||
return true, nil
|
||||
case kstatus.InProgressStatus:
|
||||
return false, nil
|
||||
default:
|
||||
return false, fmt.Errorf("%s", result.Message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,6 @@ import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
|
||||
helmv2 "github.com/fluxcd/helm-controller/api/v2"
|
||||
sourcev1 "github.com/fluxcd/source-controller/api/v1"
|
||||
@@ -67,7 +66,7 @@ func (obj helmReleaseAdapter) reconcileSource() bool {
|
||||
return rhrArgs.syncHrWithSource
|
||||
}
|
||||
|
||||
func (obj helmReleaseAdapter) getSource() (reconcileSource, types.NamespacedName) {
|
||||
func (obj helmReleaseAdapter) getSource() (reconcileSource, sourceReference) {
|
||||
var (
|
||||
name string
|
||||
ns string
|
||||
@@ -78,21 +77,26 @@ func (obj helmReleaseAdapter) getSource() (reconcileSource, types.NamespacedName
|
||||
if ns == "" {
|
||||
ns = obj.Namespace
|
||||
}
|
||||
namespacedName := types.NamespacedName{
|
||||
Name: name,
|
||||
Namespace: ns,
|
||||
srcRef := sourceReference{
|
||||
kind: obj.Spec.ChartRef.Kind,
|
||||
name: name,
|
||||
namespace: ns,
|
||||
}
|
||||
if obj.Spec.ChartRef.Kind == sourcev1.HelmChartKind {
|
||||
switch obj.Spec.ChartRef.Kind {
|
||||
case sourcev1.HelmChartKind:
|
||||
return reconcileWithSourceCommand{
|
||||
apiType: helmChartType,
|
||||
object: helmChartAdapter{&sourcev1.HelmChart{}},
|
||||
force: true,
|
||||
}, namespacedName
|
||||
}, srcRef
|
||||
case sourcev1.OCIRepositoryKind:
|
||||
return reconcileCommand{
|
||||
apiType: ociRepositoryType,
|
||||
object: ociRepositoryAdapter{&sourcev1.OCIRepository{}},
|
||||
}, srcRef
|
||||
default:
|
||||
return nil, srcRef
|
||||
}
|
||||
return reconcileCommand{
|
||||
apiType: ociRepositoryType,
|
||||
object: ociRepositoryAdapter{&sourcev1.OCIRepository{}},
|
||||
}, namespacedName
|
||||
default:
|
||||
// default case assumes the HelmRelease is using a HelmChartTemplate
|
||||
ns = obj.Spec.Chart.Spec.SourceRef.Namespace
|
||||
@@ -104,9 +108,10 @@ func (obj helmReleaseAdapter) getSource() (reconcileSource, types.NamespacedName
|
||||
apiType: helmChartType,
|
||||
object: helmChartAdapter{&sourcev1.HelmChart{}},
|
||||
force: true,
|
||||
}, types.NamespacedName{
|
||||
Name: name,
|
||||
Namespace: ns,
|
||||
}, sourceReference{
|
||||
kind: sourcev1.HelmChartKind,
|
||||
name: name,
|
||||
namespace: ns,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ package main
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
|
||||
kustomizev1 "github.com/fluxcd/kustomize-controller/api/v1"
|
||||
sourcev1 "github.com/fluxcd/source-controller/api/v1"
|
||||
@@ -62,8 +61,8 @@ func (obj kustomizationAdapter) reconcileSource() bool {
|
||||
return rksArgs.syncKsWithSource
|
||||
}
|
||||
|
||||
func (obj kustomizationAdapter) getSource() (reconcileSource, types.NamespacedName) {
|
||||
var cmd reconcileCommand
|
||||
func (obj kustomizationAdapter) getSource() (reconcileSource, sourceReference) {
|
||||
var cmd reconcileSource
|
||||
switch obj.Spec.SourceRef.Kind {
|
||||
case sourcev1.OCIRepositoryKind:
|
||||
cmd = reconcileCommand{
|
||||
@@ -82,9 +81,10 @@ func (obj kustomizationAdapter) getSource() (reconcileSource, types.NamespacedNa
|
||||
}
|
||||
}
|
||||
|
||||
return cmd, types.NamespacedName{
|
||||
Name: obj.Spec.SourceRef.Name,
|
||||
Namespace: obj.Spec.SourceRef.Namespace,
|
||||
return cmd, sourceReference{
|
||||
kind: obj.Spec.SourceRef.Kind,
|
||||
name: obj.Spec.SourceRef.Name,
|
||||
namespace: obj.Spec.SourceRef.Namespace,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ package main
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
|
||||
sourcev1 "github.com/fluxcd/source-controller/api/v1"
|
||||
)
|
||||
@@ -58,8 +57,8 @@ func (obj helmChartAdapter) reconcileSource() bool {
|
||||
return rhcArgs.syncHrWithSource
|
||||
}
|
||||
|
||||
func (obj helmChartAdapter) getSource() (reconcileSource, types.NamespacedName) {
|
||||
var cmd reconcileCommand
|
||||
func (obj helmChartAdapter) getSource() (reconcileSource, sourceReference) {
|
||||
var cmd reconcileSource
|
||||
switch obj.Spec.SourceRef.Kind {
|
||||
case sourcev1.HelmRepositoryKind:
|
||||
cmd = reconcileCommand{
|
||||
@@ -78,9 +77,10 @@ func (obj helmChartAdapter) getSource() (reconcileSource, types.NamespacedName)
|
||||
}
|
||||
}
|
||||
|
||||
return cmd, types.NamespacedName{
|
||||
Name: obj.Spec.SourceRef.Name,
|
||||
Namespace: obj.Namespace,
|
||||
return cmd, sourceReference{
|
||||
kind: obj.Spec.SourceRef.Kind,
|
||||
name: obj.Spec.SourceRef.Name,
|
||||
namespace: obj.Namespace,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,11 +15,17 @@ import (
|
||||
"github.com/fluxcd/flux2/v2/internal/utils"
|
||||
)
|
||||
|
||||
type sourceReference struct {
|
||||
kind string
|
||||
name string
|
||||
namespace string
|
||||
}
|
||||
|
||||
type reconcileWithSource interface {
|
||||
adapter
|
||||
reconcilable
|
||||
reconcileSource() bool
|
||||
getSource() (reconcileSource, types.NamespacedName)
|
||||
getSource() (reconcileSource, sourceReference)
|
||||
}
|
||||
|
||||
type reconcileSource interface {
|
||||
@@ -61,14 +67,17 @@ func (reconcile reconcileWithSourceCommand) run(cmd *cobra.Command, args []strin
|
||||
}
|
||||
|
||||
if reconcile.object.reconcileSource() || reconcile.force {
|
||||
reconcileCmd, nsName := reconcile.object.getSource()
|
||||
nsCopy := *kubeconfigArgs.Namespace
|
||||
if nsName.Namespace != "" {
|
||||
*kubeconfigArgs.Namespace = nsName.Namespace
|
||||
reconcileCmd, srcRef := reconcile.object.getSource()
|
||||
if reconcileCmd == nil {
|
||||
return fmt.Errorf("cannot reconcile source of kind %s", srcRef.kind)
|
||||
}
|
||||
|
||||
err := reconcileCmd.run(nil, []string{nsName.Name})
|
||||
if err != nil {
|
||||
nsCopy := *kubeconfigArgs.Namespace
|
||||
if srcRef.namespace != "" {
|
||||
*kubeconfigArgs.Namespace = srcRef.namespace
|
||||
}
|
||||
|
||||
if err := reconcileCmd.run(nil, []string{srcRef.name}); err != nil {
|
||||
return err
|
||||
}
|
||||
*kubeconfigArgs.Namespace = nsCopy
|
||||
|
||||
+13
-1
@@ -126,6 +126,17 @@ func (resume resumeCommand) run(cmd *cobra.Command, args []string) error {
|
||||
|
||||
resume.printMessage(reconcileResps)
|
||||
|
||||
// Return an error if any reconciliation failed
|
||||
var failedCount int
|
||||
for _, r := range reconcileResps {
|
||||
if r.resumable != nil && r.err != nil {
|
||||
failedCount++
|
||||
}
|
||||
}
|
||||
if failedCount > 0 {
|
||||
return fmt.Errorf("reconciliation failed for %d %s(s)", failedCount, resume.kind)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -251,7 +262,8 @@ func (resume resumeCommand) printMessage(responses []reconcileResponse) {
|
||||
continue
|
||||
}
|
||||
if r.err != nil {
|
||||
logger.Failuref("%s", r.err.Error())
|
||||
logger.Failuref("%s %s reconciliation failed: %s", resume.kind, r.asClientObject().GetName(), r.err.Error())
|
||||
continue
|
||||
}
|
||||
logger.Successf("%s %s reconciliation completed", resume.kind, r.asClientObject().GetName())
|
||||
logger.Successf("%s", r.successMessage())
|
||||
|
||||
@@ -195,3 +195,37 @@ func (a helmRepositoryListAdapter) asClientList() client.ObjectList {
|
||||
func (a helmRepositoryListAdapter) len() int {
|
||||
return len(a.HelmRepositoryList.Items)
|
||||
}
|
||||
|
||||
// sourcev1.ExternalArtifact
|
||||
|
||||
var externalArtifactType = apiType{
|
||||
kind: sourcev1.ExternalArtifactKind,
|
||||
humanKind: "source external-artifact",
|
||||
groupVersion: sourcev1.GroupVersion,
|
||||
}
|
||||
|
||||
type externalArtifactAdapter struct {
|
||||
*sourcev1.ExternalArtifact
|
||||
}
|
||||
|
||||
func (a externalArtifactAdapter) asClientObject() client.Object {
|
||||
return a.ExternalArtifact
|
||||
}
|
||||
|
||||
func (a externalArtifactAdapter) deepCopyClientObject() client.Object {
|
||||
return a.ExternalArtifact.DeepCopy()
|
||||
}
|
||||
|
||||
// sourcev1.ExternalArtifactList
|
||||
|
||||
type externalArtifactListAdapter struct {
|
||||
*sourcev1.ExternalArtifactList
|
||||
}
|
||||
|
||||
func (a externalArtifactListAdapter) asClientList() client.ObjectList {
|
||||
return a.ExternalArtifactList
|
||||
}
|
||||
|
||||
func (a externalArtifactListAdapter) len() int {
|
||||
return len(a.ExternalArtifactList.Items)
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@ func tagArtifactCmdRun(cmd *cobra.Command, args []string) error {
|
||||
|
||||
if tagArtifactArgs.provider.String() != sourcev1.GenericOCIProvider {
|
||||
logger.Actionf("logging in to registry with provider credentials")
|
||||
opt, err := loginWithProvider(ctx, url, tagArtifactArgs.provider.String())
|
||||
opt, _, err := loginWithProvider(ctx, url, tagArtifactArgs.provider.String())
|
||||
if err != nil {
|
||||
return fmt.Errorf("error during login with provider: %w", err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
-----BEGIN OPENSSH PRIVATE KEY-----
|
||||
b3BlbnNzaC1rZXktdjEAAAAACmFlczI1Ni1jdHIAAAAGYmNyeXB0AAAAGAAAABDuUiEMA0
|
||||
eUvKlmOsur2w9FAAAAGAAAAAEAAAAzAAAAC3NzaC1lZDI1NTE5AAAAIDF/w86ZQb5qmZtv
|
||||
m1GvyLojiJdhmPtI9hJ9XPcP7HBoAAAAkG2cIOuSVdWInSC0P81ExiorUpiAGOjxxpgvKW
|
||||
VYERfU1zU72Z/c9n1+z/IH5cJOhZ1vlqBO0rubl4s0KQFvY/LKcsc4N0x0uzpqrvcJP4tO
|
||||
9VW8LrMnrPp7b6KVJPsbeSW1SBcUM24aCMzF4/wV03mN/Uqz30s+YgS9SU4Lz8AOkX58xX
|
||||
yAV0gkmndIzZl+Og==
|
||||
-----END OPENSSH PRIVATE KEY-----
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
-----BEGIN OPENSSH PRIVATE KEY-----
|
||||
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
|
||||
QyNTUxOQAAACAWDldtCFdSMXIV1vLwXvRwk4eEmSoDCpxNkcbNph3dCAAAAIjjSDmx40g5
|
||||
sQAAAAtzc2gtZWQyNTUxOQAAACAWDldtCFdSMXIV1vLwXvRwk4eEmSoDCpxNkcbNph3dCA
|
||||
AAAEAGpzSFuLkCNDD49+tysxSFFwdOsRnDj67vDT9bfwoSDhYOV20IV1IxchXW8vBe9HCT
|
||||
h4SZKgMKnE2Rxs2mHd0IAAAABHRlc3QB
|
||||
-----END OPENSSH PRIVATE KEY-----
|
||||
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user