mirror of
https://github.com/cozystack/cozystack.git
synced 2026-03-03 21:48:57 +00:00
Compare commits
172 Commits
release-0.
...
remove-ass
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
725f70399f | ||
|
|
49f7ac9192 | ||
|
|
ab8cc5ffd4 | ||
|
|
f5e6107e3a | ||
|
|
0fb02e6470 | ||
|
|
3a1e7fdd8f | ||
|
|
05b2244b36 | ||
|
|
d2fc6f8470 | ||
|
|
daa91fd2f3 | ||
|
|
43df3a1b70 | ||
|
|
7b75903cee | ||
|
|
07b406e9bc | ||
|
|
5f36396ccc | ||
|
|
811fde9993 | ||
|
|
695fc05dec | ||
|
|
2e61810547 | ||
|
|
36836fd84e | ||
|
|
bf1928c96f | ||
|
|
069a3ca9b0 | ||
|
|
f4228ffc20 | ||
|
|
bfafcaa3ab | ||
|
|
f59665208c | ||
|
|
66a756b606 | ||
|
|
a8688744e9 | ||
|
|
7a964eb7de | ||
|
|
3a5977ff60 | ||
|
|
bbeaaccd0c | ||
|
|
08e5a25ce7 | ||
|
|
dd0bbd375f | ||
|
|
d26d99c925 | ||
|
|
675eaa6178 | ||
|
|
5638a7eae9 | ||
|
|
1fc48da514 | ||
|
|
2d6e50bbeb | ||
|
|
dea42d035f | ||
|
|
f3722d0b2a | ||
|
|
f4e43469dc | ||
|
|
7e86a04fab | ||
|
|
935addebe9 | ||
|
|
a902eaec91 | ||
|
|
c200a45824 | ||
|
|
c06f399d20 | ||
|
|
f28d639aea | ||
|
|
63baadfd9a | ||
|
|
2d4e568efe | ||
|
|
b94fc6c9c8 | ||
|
|
36ef62af52 | ||
|
|
715509f57e | ||
|
|
db8a006565 | ||
|
|
7fab919e07 | ||
|
|
c43db3b7cc | ||
|
|
14803a4162 | ||
|
|
3a1f4ab854 | ||
|
|
307f20f8af | ||
|
|
5af0d132a5 | ||
|
|
a03042e017 | ||
|
|
4dfdbfeb62 | ||
|
|
da56c88aa7 | ||
|
|
e9cad4507b | ||
|
|
19e06f7beb | ||
|
|
0b16f83eae | ||
|
|
8cf68149cb | ||
|
|
cbc998b2ec | ||
|
|
fe9fca6abf | ||
|
|
d3f1b821f7 | ||
|
|
7165cea57a | ||
|
|
2990f0520a | ||
|
|
d7931fdb20 | ||
|
|
0697c0221b | ||
|
|
59f62b7834 | ||
|
|
f28b53d8dc | ||
|
|
e5c3492089 | ||
|
|
9d85cfb647 | ||
|
|
db800b510e | ||
|
|
202410a438 | ||
|
|
f7877b9bce | ||
|
|
ab6c6bad16 | ||
|
|
09e0b5f4ec | ||
|
|
e31ee932c0 | ||
|
|
517369629f | ||
|
|
00cef35214 | ||
|
|
e213b068e8 | ||
|
|
98488100e4 | ||
|
|
0ed52f670d | ||
|
|
b6900a258a | ||
|
|
c919ea6e39 | ||
|
|
7fa875db52 | ||
|
|
cf15ad1073 | ||
|
|
ba50d01877 | ||
|
|
7e7716aa44 | ||
|
|
669bf3d2f5 | ||
|
|
8d43f993e4 | ||
|
|
fe7bdcf06b | ||
|
|
733221286d | ||
|
|
9820d0c4b3 | ||
|
|
1f0f14cf18 | ||
|
|
df3a409142 | ||
|
|
97e8d2aa49 | ||
|
|
1805be3c48 | ||
|
|
c0e0ef0f7c | ||
|
|
1f0b5ff9ac | ||
|
|
1ec14d6bd6 | ||
|
|
03a71eb8de | ||
|
|
ee2a34ca81 | ||
|
|
0f7bd3e395 | ||
|
|
0d71525f7e | ||
|
|
8866a307bf | ||
|
|
10d35742e2 | ||
|
|
61ec812a3e | ||
|
|
373a0d1359 | ||
|
|
680f70c03a | ||
|
|
b1ba1f2172 | ||
|
|
e3b96e12be | ||
|
|
4f5ae287f5 | ||
|
|
6b8c490b1d | ||
|
|
90c725194f | ||
|
|
a05cc3512e | ||
|
|
8513dd6b3f | ||
|
|
0bab895026 | ||
|
|
349677ffe9 | ||
|
|
1f47fbc3dd | ||
|
|
d079dd4731 | ||
|
|
f3207fcd10 | ||
|
|
650e5290ea | ||
|
|
3975da93c6 | ||
|
|
19586e1eec | ||
|
|
578a810413 | ||
|
|
89897914fa | ||
|
|
892855276b | ||
|
|
58dd1f5881 | ||
|
|
67ecf3d0f6 | ||
|
|
38d6b98a70 | ||
|
|
0a93972c4f | ||
|
|
da4d6053bb | ||
|
|
28c933161a | ||
|
|
e50950b7a1 | ||
|
|
b1a55f5a38 | ||
|
|
1ee6eb8482 | ||
|
|
cbfa99148b | ||
|
|
54d0f52245 | ||
|
|
d0bfb6e2fc | ||
|
|
f86896eceb | ||
|
|
cb320f9d48 | ||
|
|
a7b423934f | ||
|
|
b1a7e9560e | ||
|
|
fe9d334880 | ||
|
|
1e2b66131c | ||
|
|
8d14bcb598 | ||
|
|
825390c209 | ||
|
|
09fd7c4094 | ||
|
|
8928731abf | ||
|
|
153635379a | ||
|
|
fbb2ea095a | ||
|
|
50c1d1a067 | ||
|
|
52ebcae8a2 | ||
|
|
5314d61987 | ||
|
|
8b29c53a45 | ||
|
|
4e4a5606d7 | ||
|
|
f2a4f1b1c8 | ||
|
|
33128748e6 | ||
|
|
7e1cad26e7 | ||
|
|
3d5118f5b3 | ||
|
|
06a25c1c45 | ||
|
|
13e0501acd | ||
|
|
ca29fc855a | ||
|
|
67e47256e2 | ||
|
|
df277b350c | ||
|
|
644d71eef7 | ||
|
|
8e351f1827 | ||
|
|
38a4adfaa3 | ||
|
|
03885f5ae2 | ||
|
|
d26d3e1f40 |
188
.github/workflows/auto-release.yaml
vendored
Normal file
188
.github/workflows/auto-release.yaml
vendored
Normal file
@@ -0,0 +1,188 @@
|
||||
name: Auto Patch Release
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Run daily at 2:00 AM CET (1:00 UTC in winter, 0:00 UTC in summer)
|
||||
# Using 1:00 UTC to approximate 2:00 AM CET
|
||||
- cron: '0 1 * * *'
|
||||
workflow_dispatch: # Allow manual trigger
|
||||
|
||||
concurrency:
|
||||
group: auto-release-${{ github.workflow }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
auto-release:
|
||||
name: Auto Patch Release
|
||||
runs-on: [self-hosted]
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: read
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
|
||||
- name: Configure git
|
||||
env:
|
||||
GH_PAT: ${{ secrets.GH_PAT }}
|
||||
run: |
|
||||
git config user.name "cozystack-bot"
|
||||
git config user.email "217169706+cozystack-bot@users.noreply.github.com"
|
||||
git remote set-url origin https://cozystack-bot:${GH_PAT}@github.com/${GITHUB_REPOSITORY}
|
||||
git config --unset-all http.https://github.com/.extraheader || true
|
||||
|
||||
- name: Process release branches
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
GH_PAT: ${{ secrets.GH_PAT }}
|
||||
with:
|
||||
github-token: ${{ secrets.GH_PAT }}
|
||||
script: |
|
||||
const { execSync } = require('child_process');
|
||||
|
||||
// Configure git to use PAT for authentication
|
||||
execSync('git config user.name "cozystack-bot"', { encoding: 'utf8' });
|
||||
execSync('git config user.email "217169706+cozystack-bot@users.noreply.github.com"', { encoding: 'utf8' });
|
||||
execSync(`git remote set-url origin https://cozystack-bot:${process.env.GH_PAT}@github.com/${process.env.GITHUB_REPOSITORY}`, { encoding: 'utf8' });
|
||||
// Remove GITHUB_TOKEN extraheader to ensure PAT is used (needed to trigger other workflows)
|
||||
execSync('git config --unset-all http.https://github.com/.extraheader || true', { encoding: 'utf8' });
|
||||
|
||||
// Get all release-X.Y branches
|
||||
const branches = execSync('git branch -r | grep -E "origin/release-[0-9]+\\.[0-9]+$" | sed "s|origin/||" | tr -d " "', { encoding: 'utf8' })
|
||||
.split('\n')
|
||||
.filter(b => b.trim())
|
||||
.filter(b => /^release-\d+\.\d+$/.test(b));
|
||||
|
||||
console.log(`Found ${branches.length} release branches: ${branches.join(', ')}`);
|
||||
|
||||
// Get all published releases (not draft)
|
||||
const allReleases = await github.rest.repos.listReleases({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
per_page: 100
|
||||
});
|
||||
|
||||
// Filter to only published releases (not draft) with tags matching vX.Y.Z (no suffixes)
|
||||
const publishedReleases = allReleases.data
|
||||
.filter(r => !r.draft)
|
||||
.filter(r => /^v\d+\.\d+\.\d+$/.test(r.tag_name));
|
||||
|
||||
console.log(`Found ${publishedReleases.length} published releases without suffixes`);
|
||||
|
||||
for (const branch of branches) {
|
||||
console.log(`\n=== Processing branch: ${branch} ===`);
|
||||
|
||||
// Extract X.Y from branch name (release-X.Y)
|
||||
const match = branch.match(/^release-(\d+\.\d+)$/);
|
||||
if (!match) {
|
||||
console.log(` ⚠️ Branch ${branch} doesn't match pattern, skipping`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const [major, minor] = match[1].split('.');
|
||||
const versionPrefix = `v${major}.${minor}.`;
|
||||
|
||||
console.log(` Looking for releases with prefix: ${versionPrefix}`);
|
||||
|
||||
// Find the latest published release for this branch (vX.Y.Z without suffixes)
|
||||
const branchReleases = publishedReleases
|
||||
.filter(r => r.tag_name.startsWith(versionPrefix))
|
||||
.filter(r => /^v\d+\.\d+\.\d+$/.test(r.tag_name)); // Ensure no suffixes
|
||||
|
||||
if (branchReleases.length === 0) {
|
||||
console.log(` ⚠️ No published releases found for ${branch}, skipping`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Sort by version (descending) to get the latest
|
||||
branchReleases.sort((a, b) => {
|
||||
const aVersion = a.tag_name.match(/^v(\d+)\.(\d+)\.(\d+)$/);
|
||||
const bVersion = b.tag_name.match(/^v(\d+)\.(\d+)\.(\d+)$/);
|
||||
if (!aVersion || !bVersion) return 0;
|
||||
|
||||
const aNum = parseInt(aVersion[1]) * 10000 + parseInt(aVersion[2]) * 100 + parseInt(aVersion[3]);
|
||||
const bNum = parseInt(bVersion[1]) * 10000 + parseInt(bVersion[2]) * 100 + parseInt(bVersion[3]);
|
||||
return bNum - aNum;
|
||||
});
|
||||
|
||||
const latestRelease = branchReleases[0];
|
||||
console.log(` ✅ Latest published release: ${latestRelease.tag_name}`);
|
||||
|
||||
// Get the commit SHA for this release tag
|
||||
let releaseCommitSha;
|
||||
try {
|
||||
releaseCommitSha = execSync(`git rev-list -n 1 ${latestRelease.tag_name}`, { encoding: 'utf8' }).trim();
|
||||
console.log(` Release commit SHA: ${releaseCommitSha}`);
|
||||
} catch (error) {
|
||||
console.log(` ⚠️ Could not find commit for tag ${latestRelease.tag_name}, skipping`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Checkout the branch
|
||||
execSync(`git fetch origin ${branch}:${branch}`, { encoding: 'utf8' });
|
||||
execSync(`git checkout ${branch}`, { encoding: 'utf8' });
|
||||
|
||||
// Get the latest commit on the branch
|
||||
const latestBranchCommit = execSync('git rev-parse HEAD', { encoding: 'utf8' }).trim();
|
||||
console.log(` Latest branch commit: ${latestBranchCommit}`);
|
||||
|
||||
// Check if there are new commits after the release
|
||||
const commitsAfterRelease = execSync(
|
||||
`git rev-list ${releaseCommitSha}..HEAD --oneline`,
|
||||
{ encoding: 'utf8' }
|
||||
).trim();
|
||||
|
||||
if (!commitsAfterRelease) {
|
||||
console.log(` ℹ️ No new commits after ${latestRelease.tag_name}, skipping`);
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(` ✅ Found new commits after release:`);
|
||||
console.log(commitsAfterRelease);
|
||||
|
||||
// Calculate next version (Z+1)
|
||||
const versionMatch = latestRelease.tag_name.match(/^v(\d+)\.(\d+)\.(\d+)$/);
|
||||
if (!versionMatch) {
|
||||
console.log(` ❌ Could not parse version from ${latestRelease.tag_name}, skipping`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const nextPatch = parseInt(versionMatch[3]) + 1;
|
||||
const nextTag = `v${versionMatch[1]}.${versionMatch[2]}.${nextPatch}`;
|
||||
|
||||
console.log(` 🏷️ Creating new tag: ${nextTag} on commit ${latestBranchCommit}`);
|
||||
|
||||
// Create and push the tag with base_ref for workflow triggering
|
||||
try {
|
||||
// Delete local tag if exists to force update
|
||||
try {
|
||||
execSync(`git tag -d ${nextTag}`, { encoding: 'utf8' });
|
||||
} catch (e) {
|
||||
// Tag doesn't exist locally, that's fine
|
||||
}
|
||||
|
||||
// Delete remote tag if exists
|
||||
try {
|
||||
execSync(`git push origin :refs/tags/${nextTag}`, { encoding: 'utf8' });
|
||||
} catch (e) {
|
||||
// Tag doesn't exist remotely, that's fine
|
||||
}
|
||||
|
||||
// Create tag locally
|
||||
execSync(`git tag ${nextTag} ${latestBranchCommit}`, { encoding: 'utf8' });
|
||||
|
||||
// Push tag with HEAD reference to preserve base_ref
|
||||
execSync(`git push origin HEAD:refs/tags/${nextTag}`, { encoding: 'utf8' });
|
||||
console.log(` ✅ Successfully created and pushed tag ${nextTag}`);
|
||||
} catch (error) {
|
||||
console.log(` ❌ Error creating/pushing tag ${nextTag}: ${error.message}`);
|
||||
core.setFailed(`Failed to create tag ${nextTag} for branch ${branch}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n✅ Finished processing all release branches`);
|
||||
|
||||
104
.github/workflows/backport.yaml
vendored
104
.github/workflows/backport.yaml
vendored
@@ -2,7 +2,7 @@ name: Automatic Backport
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [closed] # fires when PR is closed (merged)
|
||||
types: [closed, labeled] # fires when PR is closed (merged) or labeled
|
||||
|
||||
concurrency:
|
||||
group: backport-${{ github.workflow }}-${{ github.event.pull_request.number }}
|
||||
@@ -13,22 +13,46 @@ permissions:
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
backport:
|
||||
# Determine which backports are needed
|
||||
prepare:
|
||||
if: |
|
||||
github.event.pull_request.merged == true &&
|
||||
contains(github.event.pull_request.labels.*.name, 'backport')
|
||||
(
|
||||
contains(github.event.pull_request.labels.*.name, 'backport') ||
|
||||
contains(github.event.pull_request.labels.*.name, 'backport-previous') ||
|
||||
(github.event.action == 'labeled' && (github.event.label.name == 'backport' || github.event.label.name == 'backport-previous'))
|
||||
)
|
||||
runs-on: [self-hosted]
|
||||
|
||||
outputs:
|
||||
backport_current: ${{ steps.labels.outputs.backport }}
|
||||
backport_previous: ${{ steps.labels.outputs.backport_previous }}
|
||||
current_branch: ${{ steps.branches.outputs.current_branch }}
|
||||
previous_branch: ${{ steps.branches.outputs.previous_branch }}
|
||||
steps:
|
||||
# 1. Decide which maintenance branch should receive the back‑port
|
||||
- name: Determine target maintenance branch
|
||||
id: target
|
||||
- name: Check which labels are present
|
||||
id: labels
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
let rel;
|
||||
const pr = context.payload.pull_request;
|
||||
const labels = pr.labels.map(l => l.name);
|
||||
const isBackport = labels.includes('backport');
|
||||
const isBackportPrevious = labels.includes('backport-previous');
|
||||
|
||||
core.setOutput('backport', isBackport ? 'true' : 'false');
|
||||
core.setOutput('backport_previous', isBackportPrevious ? 'true' : 'false');
|
||||
|
||||
console.log(`backport label: ${isBackport}, backport-previous label: ${isBackportPrevious}`);
|
||||
|
||||
- name: Determine target branches
|
||||
id: branches
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
// Get latest release
|
||||
let latestRelease;
|
||||
try {
|
||||
rel = await github.rest.repos.getLatestRelease({
|
||||
latestRelease = await github.rest.repos.getLatestRelease({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo
|
||||
});
|
||||
@@ -36,18 +60,70 @@ jobs:
|
||||
core.setFailed('No existing releases found; cannot determine backport target.');
|
||||
return;
|
||||
}
|
||||
const [maj, min] = rel.data.tag_name.replace(/^v/, '').split('.');
|
||||
const branch = `release-${maj}.${min}`;
|
||||
core.setOutput('branch', branch);
|
||||
console.log(`Latest release ${rel.data.tag_name}; backporting to ${branch}`);
|
||||
|
||||
const [maj, min] = latestRelease.data.tag_name.replace(/^v/, '').split('.');
|
||||
const currentBranch = `release-${maj}.${min}`;
|
||||
const prevMin = parseInt(min) - 1;
|
||||
const previousBranch = prevMin >= 0 ? `release-${maj}.${prevMin}` : '';
|
||||
|
||||
core.setOutput('current_branch', currentBranch);
|
||||
core.setOutput('previous_branch', previousBranch);
|
||||
|
||||
console.log(`Current branch: ${currentBranch}, Previous branch: ${previousBranch || 'N/A'}`);
|
||||
|
||||
// Verify previous branch exists if we need it
|
||||
if (previousBranch && '${{ steps.labels.outputs.backport_previous }}' === 'true') {
|
||||
try {
|
||||
await github.rest.repos.getBranch({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
branch: previousBranch
|
||||
});
|
||||
console.log(`Previous branch ${previousBranch} exists`);
|
||||
} catch (e) {
|
||||
core.setFailed(`Previous branch ${previousBranch} does not exist.`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
backport:
|
||||
needs: prepare
|
||||
if: |
|
||||
github.event.pull_request.merged == true &&
|
||||
(needs.prepare.outputs.backport_current == 'true' || needs.prepare.outputs.backport_previous == 'true')
|
||||
runs-on: [self-hosted]
|
||||
strategy:
|
||||
matrix:
|
||||
backport_type: [current, previous]
|
||||
steps:
|
||||
# 1. Determine target branch based on matrix
|
||||
- name: Set target branch
|
||||
id: target
|
||||
if: |
|
||||
(matrix.backport_type == 'current' && needs.prepare.outputs.backport_current == 'true') ||
|
||||
(matrix.backport_type == 'previous' && needs.prepare.outputs.backport_previous == 'true')
|
||||
run: |
|
||||
if [ "${{ matrix.backport_type }}" == "current" ]; then
|
||||
echo "branch=${{ needs.prepare.outputs.current_branch }}" >> $GITHUB_OUTPUT
|
||||
echo "Target branch: ${{ needs.prepare.outputs.current_branch }}"
|
||||
else
|
||||
echo "branch=${{ needs.prepare.outputs.previous_branch }}" >> $GITHUB_OUTPUT
|
||||
echo "Target branch: ${{ needs.prepare.outputs.previous_branch }}"
|
||||
fi
|
||||
|
||||
# 2. Checkout (required by backport‑action)
|
||||
- name: Checkout repository
|
||||
if: steps.target.outcome == 'success'
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# 3. Create the back‑port pull request
|
||||
- name: Create back‑port PR
|
||||
uses: korthout/backport-action@v3
|
||||
id: backport
|
||||
if: steps.target.outcome == 'success'
|
||||
uses: korthout/backport-action@v3.2.1
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
label_pattern: '' # don't read labels for targets
|
||||
target_branches: ${{ steps.target.outputs.branch }}
|
||||
merge_commits: skip
|
||||
conflict_resolution: draft_commit_conflicts
|
||||
|
||||
2
.github/workflows/pre-commit.yml
vendored
2
.github/workflows/pre-commit.yml
vendored
@@ -28,7 +28,7 @@ jobs:
|
||||
|
||||
- name: Install generate
|
||||
run: |
|
||||
curl -sSL https://github.com/cozystack/cozyvalues-gen/releases/download/v1.0.5/cozyvalues-gen-linux-amd64.tar.gz | tar -xzvf- -C /usr/local/bin/ cozyvalues-gen
|
||||
curl -sSL https://github.com/cozystack/cozyvalues-gen/releases/download/v1.0.6/cozyvalues-gen-linux-amd64.tar.gz | tar -xzvf- -C /usr/local/bin/ cozyvalues-gen
|
||||
|
||||
- name: Run pre-commit hooks
|
||||
run: |
|
||||
|
||||
143
.github/workflows/pull-requests-release.yaml
vendored
143
.github/workflows/pull-requests-release.yaml
vendored
@@ -46,7 +46,12 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Create tag on merge commit
|
||||
env:
|
||||
GH_PAT: ${{ secrets.GH_PAT }}
|
||||
run: |
|
||||
git config user.name "cozystack-bot"
|
||||
git config user.email "217169706+cozystack-bot@users.noreply.github.com"
|
||||
git remote set-url origin https://cozystack-bot:${GH_PAT}@github.com/${GITHUB_REPOSITORY}
|
||||
git tag -f ${{ steps.get_tag.outputs.tag }} ${{ github.sha }}
|
||||
git push -f origin ${{ steps.get_tag.outputs.tag }}
|
||||
|
||||
@@ -105,67 +110,95 @@ jobs:
|
||||
}
|
||||
}
|
||||
|
||||
# Get the latest published release
|
||||
- name: Get the latest published release
|
||||
id: latest_release
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
try {
|
||||
const rel = await github.rest.repos.getLatestRelease({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo
|
||||
});
|
||||
core.setOutput('tag', rel.data.tag_name);
|
||||
} catch (_) {
|
||||
core.setOutput('tag', '');
|
||||
}
|
||||
|
||||
# Compare current tag vs latest using semver-utils
|
||||
- name: Semver compare
|
||||
id: semver
|
||||
uses: madhead/semver-utils@v4.3.0
|
||||
with:
|
||||
version: ${{ steps.get_tag.outputs.tag }}
|
||||
compare-to: ${{ steps.latest_release.outputs.tag }}
|
||||
|
||||
# Derive flags: prerelease? make_latest?
|
||||
- name: Calculate publish flags
|
||||
id: flags
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const tag = '${{ steps.get_tag.outputs.tag }}'; // v0.31.5-rc.1
|
||||
const m = tag.match(/^v(\d+\.\d+\.\d+)(-(?:alpha|beta|rc)\.\d+)?$/);
|
||||
if (!m) {
|
||||
core.setFailed(`❌ tag '${tag}' must match 'vX.Y.Z' or 'vX.Y.Z-(alpha|beta|rc).N'`);
|
||||
return;
|
||||
}
|
||||
const version = m[1] + (m[2] ?? ''); // 0.31.5-rc.1
|
||||
const isRc = Boolean(m[2]);
|
||||
core.setOutput('is_rc', isRc);
|
||||
const outdated = '${{ steps.semver.outputs.comparison-result }}' === '<';
|
||||
core.setOutput('make_latest', isRc || outdated ? 'false' : 'legacy');
|
||||
|
||||
# Publish draft release with correct flags
|
||||
# Publish draft release and ensure correct latest flag
|
||||
- name: Publish draft release
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const tag = '${{ steps.get_tag.outputs.tag }}';
|
||||
const m = tag.match(/^v(\d+\.\d+\.\d+)(-(?:alpha|beta|rc)\.\d+)?$/);
|
||||
if (!m) {
|
||||
core.setFailed(`❌ tag '${tag}' must match 'vX.Y.Z' or 'vX.Y.Z-(alpha|beta|rc).N'`);
|
||||
return;
|
||||
}
|
||||
const isRc = Boolean(m[2]);
|
||||
|
||||
// Parse semver string to comparable numbers
|
||||
function parseSemver(v) {
|
||||
const match = v.replace(/^v/, '').match(/^(\d+)\.(\d+)\.(\d+)/);
|
||||
if (!match) return null;
|
||||
return {
|
||||
major: parseInt(match[1]),
|
||||
minor: parseInt(match[2]),
|
||||
patch: parseInt(match[3])
|
||||
};
|
||||
}
|
||||
|
||||
// Compare two semver objects
|
||||
function compareSemver(a, b) {
|
||||
if (a.major !== b.major) return a.major - b.major;
|
||||
if (a.minor !== b.minor) return a.minor - b.minor;
|
||||
return a.patch - b.patch;
|
||||
}
|
||||
|
||||
const currentSemver = parseSemver(tag);
|
||||
|
||||
// Get all releases
|
||||
const releases = await github.rest.repos.listReleases({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo
|
||||
});
|
||||
const draft = releases.data.find(r => r.tag_name === tag && r.draft);
|
||||
if (!draft) throw new Error(`Draft release for ${tag} not found`);
|
||||
await github.rest.repos.updateRelease({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
release_id: draft.id,
|
||||
draft: false,
|
||||
prerelease: ${{ steps.flags.outputs.is_rc }},
|
||||
make_latest: '${{ steps.flags.outputs.make_latest }}'
|
||||
repo: context.repo.repo,
|
||||
per_page: 100
|
||||
});
|
||||
|
||||
console.log(`🚀 Published release for ${tag}`);
|
||||
// Find draft release to publish
|
||||
const draft = releases.data.find(r => r.tag_name === tag && r.draft);
|
||||
if (!draft) throw new Error(`Draft release for ${tag} not found`);
|
||||
|
||||
// Find max semver among published releases (excluding current draft)
|
||||
const publishedReleases = releases.data
|
||||
.filter(r => !r.draft && !r.prerelease)
|
||||
.filter(r => /^v\d+\.\d+\.\d+$/.test(r.tag_name))
|
||||
.map(r => ({ id: r.id, tag: r.tag_name, semver: parseSemver(r.tag_name) }))
|
||||
.filter(r => r.semver !== null);
|
||||
|
||||
let maxRelease = null;
|
||||
for (const rel of publishedReleases) {
|
||||
if (!maxRelease || compareSemver(rel.semver, maxRelease.semver) > 0) {
|
||||
maxRelease = rel;
|
||||
}
|
||||
}
|
||||
|
||||
// Determine if this release should be latest
|
||||
const isOutdated = maxRelease && compareSemver(currentSemver, maxRelease.semver) < 0;
|
||||
const makeLatest = (isRc || isOutdated) ? 'false' : 'true';
|
||||
|
||||
if (isRc) {
|
||||
console.log(`🏷️ ${tag} is a prerelease, make_latest: false`);
|
||||
} else if (isOutdated) {
|
||||
console.log(`🏷️ ${tag} < ${maxRelease.tag} (max semver), make_latest: false`);
|
||||
} else {
|
||||
console.log(`🏷️ ${tag} is the highest version, make_latest: true`);
|
||||
}
|
||||
|
||||
// Publish the release
|
||||
await github.rest.repos.updateRelease({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
release_id: draft.id,
|
||||
draft: false,
|
||||
prerelease: isRc,
|
||||
make_latest: makeLatest
|
||||
});
|
||||
console.log(`🚀 Published release ${tag}`);
|
||||
|
||||
// If this is a backport/outdated release, ensure the correct release is marked as latest
|
||||
if (isOutdated && maxRelease) {
|
||||
console.log(`🔧 Ensuring ${maxRelease.tag} remains the latest release...`);
|
||||
await github.rest.repos.updateRelease({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
release_id: maxRelease.id,
|
||||
make_latest: 'true'
|
||||
});
|
||||
console.log(`✅ Restored ${maxRelease.tag} as latest release`);
|
||||
}
|
||||
|
||||
2
.github/workflows/pull-requests.yaml
vendored
2
.github/workflows/pull-requests.yaml
vendored
@@ -58,7 +58,7 @@ jobs:
|
||||
DOCKER_CONFIG: ${{ runner.temp }}/.docker
|
||||
|
||||
- name: Build Talos image
|
||||
run: make -C packages/core/installer talos-nocloud
|
||||
run: make -C packages/core/talos talos-nocloud
|
||||
|
||||
- name: Save git diff as patch
|
||||
if: "!contains(github.event.pull_request.labels.*.name, 'release')"
|
||||
|
||||
78
.github/workflows/retest.yaml
vendored
Normal file
78
.github/workflows/retest.yaml
vendored
Normal file
@@ -0,0 +1,78 @@
|
||||
name: Retest
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
jobs:
|
||||
retest:
|
||||
name: Retest PR
|
||||
runs-on: ubuntu-latest
|
||||
if: |
|
||||
github.event.issue.pull_request &&
|
||||
contains(github.event.comment.body, '/retest')
|
||||
permissions:
|
||||
actions: write
|
||||
pull-requests: read
|
||||
|
||||
steps:
|
||||
- name: Rerun from Prepare environment
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const prNumber = context.issue.number;
|
||||
|
||||
// Get the PR to find the head SHA
|
||||
const pr = await github.rest.pulls.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: prNumber
|
||||
});
|
||||
|
||||
// Find the latest workflow run for this PR
|
||||
const runs = await github.rest.actions.listWorkflowRuns({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
workflow_id: 'pull-requests.yaml',
|
||||
head_sha: pr.data.head.sha
|
||||
});
|
||||
|
||||
if (runs.data.workflow_runs.length === 0) {
|
||||
core.setFailed('No workflow runs found for this PR');
|
||||
return;
|
||||
}
|
||||
|
||||
const latestRun = runs.data.workflow_runs[0];
|
||||
console.log(`Found workflow run: ${latestRun.id} (${latestRun.status})`);
|
||||
|
||||
// Check if workflow is waiting for approval (fork PRs)
|
||||
if (latestRun.conclusion === 'action_required') {
|
||||
core.setFailed('Workflow is waiting for approval. A maintainer must approve the workflow first.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get jobs for this run
|
||||
const jobs = await github.rest.actions.listJobsForWorkflowRun({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
run_id: latestRun.id
|
||||
});
|
||||
|
||||
// Find "Prepare environment" job
|
||||
const prepareJob = jobs.data.jobs.find(j => j.name === 'Prepare environment');
|
||||
|
||||
if (!prepareJob) {
|
||||
core.setFailed('Could not find "Prepare environment" job');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Found job: ${prepareJob.name} (id: ${prepareJob.id}, status: ${prepareJob.status})`);
|
||||
|
||||
// Rerun the job
|
||||
await github.rest.actions.reRunJobForWorkflowRun({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
job_id: prepareJob.id
|
||||
});
|
||||
|
||||
console.log(`✅ Triggered rerun of job "${prepareJob.name}" (${prepareJob.id})`);
|
||||
26
.github/workflows/tags.yaml
vendored
26
.github/workflows/tags.yaml
vendored
@@ -123,32 +123,6 @@ jobs:
|
||||
git commit -m "Prepare release ${GITHUB_REF#refs/tags/}" -s || echo "No changes to commit"
|
||||
git push origin HEAD || true
|
||||
|
||||
# Get `latest_version` from latest published release
|
||||
- name: Get latest published release
|
||||
if: steps.check_release.outputs.skip == 'false'
|
||||
id: latest_release
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
try {
|
||||
const rel = await github.rest.repos.getLatestRelease({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo
|
||||
});
|
||||
core.setOutput('tag', rel.data.tag_name);
|
||||
} catch (_) {
|
||||
core.setOutput('tag', '');
|
||||
}
|
||||
|
||||
# Compare tag (A) with latest (B)
|
||||
- name: Semver compare
|
||||
if: steps.check_release.outputs.skip == 'false'
|
||||
id: semver
|
||||
uses: madhead/semver-utils@v4.3.0
|
||||
with:
|
||||
version: ${{ steps.tag.outputs.tag }} # A
|
||||
compare-to: ${{ steps.latest_release.outputs.tag }} # B
|
||||
|
||||
# Create or reuse draft release
|
||||
- name: Create / reuse draft release
|
||||
if: steps.check_release.outputs.skip == 'false'
|
||||
|
||||
92
.github/workflows/update-releasenotes.yaml
vendored
Normal file
92
.github/workflows/update-releasenotes.yaml
vendored
Normal file
@@ -0,0 +1,92 @@
|
||||
name: Update Release Notes
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
concurrency:
|
||||
group: update-releasenotes-${{ github.workflow }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
update-releasenotes:
|
||||
name: Update Release Notes
|
||||
runs-on: [self-hosted]
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Update release notes from changelogs
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const changelogDir = 'docs/changelogs';
|
||||
|
||||
// Get releases from first page
|
||||
const releases = await github.rest.repos.listReleases({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
per_page: 30
|
||||
});
|
||||
|
||||
console.log(`Found ${releases.data.length} releases (first page only)`);
|
||||
|
||||
// Process each release
|
||||
for (const release of releases.data) {
|
||||
const tag = release.tag_name;
|
||||
const changelogFile = `${tag}.md`;
|
||||
const changelogPath = path.join(changelogDir, changelogFile);
|
||||
|
||||
console.log(`\nProcessing release: ${tag}`);
|
||||
|
||||
// Check if changelog file exists
|
||||
if (!fs.existsSync(changelogPath)) {
|
||||
console.log(` ⚠️ Changelog file ${changelogFile} does not exist, skipping...`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Read changelog file content
|
||||
let changelogContent;
|
||||
try {
|
||||
changelogContent = fs.readFileSync(changelogPath, 'utf8');
|
||||
} catch (error) {
|
||||
console.log(` ❌ Error reading file ${changelogPath}: ${error.message}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!changelogContent.trim()) {
|
||||
console.log(` ⚠️ Changelog file ${changelogFile} is empty, skipping...`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if content is already up to date
|
||||
const currentBody = release.body || '';
|
||||
if (currentBody.trim() === changelogContent.trim()) {
|
||||
console.log(` ✓ Content is already up to date, skipping...`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Update release notes
|
||||
try {
|
||||
await github.rest.repos.updateRelease({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
release_id: release.id,
|
||||
body: changelogContent
|
||||
});
|
||||
console.log(` ✅ Successfully updated release notes for ${tag}`);
|
||||
} catch (error) {
|
||||
console.log(` ❌ Error updating release ${tag}: ${error.message}`);
|
||||
core.setFailed(`Failed to update release notes for ${tag}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,4 +32,4 @@ This list is sorted in chronological order, based on the submission date.
|
||||
| [Urmanac](https://urmanac.com) | @kingdonb | 2024-12-04 | Urmanac is the future home of a hosting platform for the knowledge base of a community of personal server enthusiasts. We use Cozystack to provide support services for web sites hosted using both conventional deployments and on SpinKube, with WASM. |
|
||||
| [Hidora](https://hikube.cloud) | @matthieu-robin | 2025-09-17 | Hidora is a Swiss cloud provider delivering managed services and infrastructure solutions through datacenters located in Switzerland, ensuring data sovereignty and reliability. Its sovereign cloud platform, Hikube, is designed to run workloads with high availability across multiple datacenters, providing enterprises with a secure and scalable foundation for their applications based on Cozystack. |
|
||||
| [QOSI](https://qosi.kz) | @tabu-a | 2025-10-04 | QOSI is a non-profit organization driving open-source adoption and digital sovereignty across Kazakhstan and Central Asia. We use Cozystack as a platform for deploying sovereign, GPU-enabled clouds and educational environments under the National AI Program. Our goal is to accelerate the region’s transition toward open, self-hosted cloud-native technologies |
|
||||
|
|
||||
| [Cloupard](https://cloupard.kz/) | @serjiott | 2025-12-18 | Cloupard is a public cloud provider offering IaaS and PaaS services via datacenters in Kazakhstan and Uzbekistan. Uses CozyStack on bare metal to extend its managed PaaS offerings. |
|
||||
|
||||
38
AGENTS.md
38
AGENTS.md
@@ -3,14 +3,38 @@
|
||||
This file provides structured guidance for AI coding assistants and agents
|
||||
working with the **Cozystack** project.
|
||||
|
||||
## Agent Documentation
|
||||
## Activation
|
||||
|
||||
| Agent | Purpose |
|
||||
|-------|---------|
|
||||
| [overview.md](./docs/agents/overview.md) | Project structure and conventions |
|
||||
| [contributing.md](./docs/agents/contributing.md) | Commits, pull requests, and git workflow |
|
||||
| [changelog.md](./docs/agents/changelog.md) | Changelog generation instructions |
|
||||
| [releasing.md](./docs/agents/releasing.md) | Release process and workflow |
|
||||
**CRITICAL**: When the user asks you to do something that matches the scope of a documented process, you MUST read the corresponding documentation file and follow the instructions exactly as written.
|
||||
|
||||
- **Commits, PRs, git operations** (e.g., "create a commit", "make a PR", "fix review comments", "rebase", "cherry-pick")
|
||||
- Read: [`contributing.md`](./docs/agents/contributing.md)
|
||||
- Action: Read the entire file and follow ALL instructions step-by-step
|
||||
|
||||
- **Changelog generation** (e.g., "generate changelog", "create changelog", "prepare changelog for version X")
|
||||
- Read: [`changelog.md`](./docs/agents/changelog.md)
|
||||
- Action: Read the entire file and follow ALL steps in the checklist. Do NOT skip any mandatory steps
|
||||
|
||||
- **Release creation** (e.g., "create release", "prepare release", "tag release", "make a release")
|
||||
- Read: [`releasing.md`](./docs/agents/releasing.md)
|
||||
- Action: Read the file and follow the referenced release process in `docs/release.md`
|
||||
|
||||
- **Project structure, conventions, code layout** (e.g., "where should I put X", "what's the convention for Y", "how is the project organized")
|
||||
- Read: [`overview.md`](./docs/agents/overview.md)
|
||||
- Action: Read relevant sections to understand project structure and conventions
|
||||
|
||||
- **General questions about contributing**
|
||||
- Read: [`contributing.md`](./docs/agents/contributing.md)
|
||||
- Action: Read the file to understand git workflow, commit format, PR process
|
||||
|
||||
**Important rules:**
|
||||
- ✅ **ONLY read the file if the task matches the documented process scope** - do not read files for tasks that don't match their purpose
|
||||
- ✅ **ALWAYS read the file FIRST** before starting the task (when applicable)
|
||||
- ✅ **Follow instructions EXACTLY** as written in the documentation
|
||||
- ✅ **Do NOT skip mandatory steps** (especially in changelog.md)
|
||||
- ✅ **Do NOT assume** you know the process - always check the documentation when the task matches
|
||||
- ❌ **Do NOT read files** for tasks that are outside their documented scope
|
||||
- 📖 **Note**: [`overview.md`](./docs/agents/overview.md) can be useful as a reference to understand project structure and conventions, even when not explicitly required by the task
|
||||
|
||||
## Project Overview
|
||||
|
||||
|
||||
17
Makefile
17
Makefile
@@ -1,4 +1,4 @@
|
||||
.PHONY: manifests repos assets unit-tests helm-unit-tests
|
||||
.PHONY: manifests assets unit-tests helm-unit-tests
|
||||
|
||||
build-deps:
|
||||
@command -V find docker skopeo jq gh helm > /dev/null
|
||||
@@ -15,9 +15,10 @@ build: build-deps
|
||||
make -C packages/extra/monitoring image
|
||||
make -C packages/system/cozystack-api image
|
||||
make -C packages/system/cozystack-controller image
|
||||
make -C packages/system/backup-controller image
|
||||
make -C packages/system/lineage-controller-webhook image
|
||||
make -C packages/system/cilium image
|
||||
make -C packages/system/kubeovn image
|
||||
make -C packages/system/linstor image
|
||||
make -C packages/system/kubeovn-webhook image
|
||||
make -C packages/system/kubeovn-plunger image
|
||||
make -C packages/system/dashboard image
|
||||
@@ -25,22 +26,18 @@ build: build-deps
|
||||
make -C packages/system/kamaji image
|
||||
make -C packages/system/bucket image
|
||||
make -C packages/system/objectstorage-controller image
|
||||
make -C packages/system/grafana-operator image
|
||||
make -C packages/core/testing image
|
||||
make -C packages/core/talos image
|
||||
make -C packages/core/installer image
|
||||
make manifests
|
||||
|
||||
repos:
|
||||
rm -rf _out
|
||||
make -C packages/system repo
|
||||
make -C packages/apps repo
|
||||
make -C packages/extra repo
|
||||
|
||||
manifests:
|
||||
mkdir -p _out/assets
|
||||
(cd packages/core/installer/; helm template -n cozy-installer installer .) > _out/assets/cozystack-installer.yaml
|
||||
(cd packages/core/installer/; helm template --namespace cozy-installer installer .) > _out/assets/cozystack-installer.yaml
|
||||
|
||||
assets:
|
||||
make -C packages/core/installer assets
|
||||
make -C packages/core/talos assets
|
||||
|
||||
test:
|
||||
make -C packages/core/testing apply
|
||||
|
||||
1
api/.gitattributes
vendored
Normal file
1
api/.gitattributes
vendored
Normal file
@@ -0,0 +1 @@
|
||||
zz_generated_deepcopy.go linguist-generated
|
||||
37
api/backups/strategy/v1alpha1/groupversion_info.go
Normal file
37
api/backups/strategy/v1alpha1/groupversion_info.go
Normal file
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
Copyright 2025.
|
||||
|
||||
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 v1alpha1 contains API Schema definitions for the v1alpha1 API group.
|
||||
// +kubebuilder:object:generate=true
|
||||
// +groupName=strategy.backups.cozystack.io
|
||||
package v1alpha1
|
||||
|
||||
import (
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
)
|
||||
|
||||
var (
|
||||
GroupVersion = schema.GroupVersion{Group: "strategy.backups.cozystack.io", Version: "v1alpha1"}
|
||||
SchemeBuilder = runtime.NewSchemeBuilder(addGroupVersion)
|
||||
AddToScheme = SchemeBuilder.AddToScheme
|
||||
)
|
||||
|
||||
func addGroupVersion(scheme *runtime.Scheme) error {
|
||||
metav1.AddToGroupVersion(scheme, GroupVersion)
|
||||
return nil
|
||||
}
|
||||
63
api/backups/strategy/v1alpha1/job_types.go
Normal file
63
api/backups/strategy/v1alpha1/job_types.go
Normal file
@@ -0,0 +1,63 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// Package v1alpha1 defines strategy.backups.cozystack.io API types.
|
||||
//
|
||||
// Group: strategy.backups.cozystack.io
|
||||
// Version: v1alpha1
|
||||
package v1alpha1
|
||||
|
||||
import (
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
)
|
||||
|
||||
func init() {
|
||||
SchemeBuilder.Register(func(s *runtime.Scheme) error {
|
||||
s.AddKnownTypes(GroupVersion,
|
||||
&Job{},
|
||||
&JobList{},
|
||||
)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
const (
|
||||
JobStrategyKind = "Job"
|
||||
)
|
||||
|
||||
// +kubebuilder:object:root=true
|
||||
// +kubebuilder:subresource:status
|
||||
// +kubebuilder:resource:scope=Cluster
|
||||
|
||||
// Job defines a backup strategy using a one-shot Job
|
||||
type Job struct {
|
||||
metav1.TypeMeta `json:",inline"`
|
||||
metav1.ObjectMeta `json:"metadata,omitempty"`
|
||||
|
||||
Spec JobSpec `json:"spec,omitempty"`
|
||||
Status JobStatus `json:"status,omitempty"`
|
||||
}
|
||||
|
||||
// +kubebuilder:object:root=true
|
||||
|
||||
// JobList contains a list of backup Jobs.
|
||||
type JobList struct {
|
||||
metav1.TypeMeta `json:",inline"`
|
||||
metav1.ListMeta `json:"metadata,omitempty"`
|
||||
Items []Job `json:"items"`
|
||||
}
|
||||
|
||||
// JobSpec specifies the desired behavior of a backup job.
|
||||
type JobSpec struct {
|
||||
// Template holds a PodTemplateSpec with the right shape to
|
||||
// run a single pod to completion and create a tarball with
|
||||
// a given apps data. Helm-like Go templates are supported.
|
||||
// The values of the source application are available under
|
||||
// `.Values`. `.Release.Name` and `.Release.Namespace` are
|
||||
// also exported.
|
||||
Template corev1.PodTemplateSpec `json:"template"`
|
||||
}
|
||||
|
||||
type JobStatus struct {
|
||||
Conditions []metav1.Condition `json:"conditions,omitempty"`
|
||||
}
|
||||
63
api/backups/strategy/v1alpha1/velero_types.go
Normal file
63
api/backups/strategy/v1alpha1/velero_types.go
Normal file
@@ -0,0 +1,63 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// Package v1alpha1 defines strategy.backups.cozystack.io API types.
|
||||
//
|
||||
// Group: strategy.backups.cozystack.io
|
||||
// Version: v1alpha1
|
||||
package v1alpha1
|
||||
|
||||
import (
|
||||
velerov1 "github.com/vmware-tanzu/velero/pkg/apis/velero/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
)
|
||||
|
||||
func init() {
|
||||
SchemeBuilder.Register(func(s *runtime.Scheme) error {
|
||||
s.AddKnownTypes(GroupVersion,
|
||||
&Velero{},
|
||||
&VeleroList{},
|
||||
)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
const (
|
||||
VeleroStrategyKind = "Velero"
|
||||
)
|
||||
|
||||
// +kubebuilder:object:root=true
|
||||
// +kubebuilder:subresource:status
|
||||
// +kubebuilder:resource:scope=Cluster
|
||||
|
||||
// Velero defines a backup strategy using Velero as the driver.
|
||||
type Velero struct {
|
||||
metav1.TypeMeta `json:",inline"`
|
||||
metav1.ObjectMeta `json:"metadata,omitempty"`
|
||||
|
||||
Spec VeleroSpec `json:"spec,omitempty"`
|
||||
Status VeleroStatus `json:"status,omitempty"`
|
||||
}
|
||||
|
||||
// +kubebuilder:object:root=true
|
||||
|
||||
// VeleroList contains a list of Velero backup strategies.
|
||||
type VeleroList struct {
|
||||
metav1.TypeMeta `json:",inline"`
|
||||
metav1.ListMeta `json:"metadata,omitempty"`
|
||||
Items []Velero `json:"items"`
|
||||
}
|
||||
|
||||
// VeleroSpec specifies the desired strategy for backing up with Velero.
|
||||
type VeleroSpec struct {
|
||||
Template VeleroTemplate `json:"template"`
|
||||
}
|
||||
|
||||
// VeleroTemplate describes the data a backup.velero.io should have when
|
||||
// templated from a Velero backup strategy.
|
||||
type VeleroTemplate struct {
|
||||
Spec velerov1.BackupSpec `json:"spec"`
|
||||
}
|
||||
|
||||
type VeleroStatus struct {
|
||||
Conditions []metav1.Condition `json:"conditions,omitempty"`
|
||||
}
|
||||
236
api/backups/strategy/v1alpha1/zz_generated.deepcopy.go
Normal file
236
api/backups/strategy/v1alpha1/zz_generated.deepcopy.go
Normal file
@@ -0,0 +1,236 @@
|
||||
//go:build !ignore_autogenerated
|
||||
|
||||
/*
|
||||
Copyright 2025 The Cozystack 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.
|
||||
*/
|
||||
|
||||
// Code generated by controller-gen. DO NOT EDIT.
|
||||
|
||||
package v1alpha1
|
||||
|
||||
import (
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
)
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *Job) DeepCopyInto(out *Job) {
|
||||
*out = *in
|
||||
out.TypeMeta = in.TypeMeta
|
||||
in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
|
||||
in.Spec.DeepCopyInto(&out.Spec)
|
||||
in.Status.DeepCopyInto(&out.Status)
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Job.
|
||||
func (in *Job) DeepCopy() *Job {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(Job)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
|
||||
func (in *Job) DeepCopyObject() runtime.Object {
|
||||
if c := in.DeepCopy(); c != nil {
|
||||
return c
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *JobList) DeepCopyInto(out *JobList) {
|
||||
*out = *in
|
||||
out.TypeMeta = in.TypeMeta
|
||||
in.ListMeta.DeepCopyInto(&out.ListMeta)
|
||||
if in.Items != nil {
|
||||
in, out := &in.Items, &out.Items
|
||||
*out = make([]Job, len(*in))
|
||||
for i := range *in {
|
||||
(*in)[i].DeepCopyInto(&(*out)[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new JobList.
|
||||
func (in *JobList) DeepCopy() *JobList {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(JobList)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
|
||||
func (in *JobList) DeepCopyObject() runtime.Object {
|
||||
if c := in.DeepCopy(); c != nil {
|
||||
return c
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *JobSpec) DeepCopyInto(out *JobSpec) {
|
||||
*out = *in
|
||||
in.Template.DeepCopyInto(&out.Template)
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new JobSpec.
|
||||
func (in *JobSpec) DeepCopy() *JobSpec {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(JobSpec)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *JobStatus) DeepCopyInto(out *JobStatus) {
|
||||
*out = *in
|
||||
if in.Conditions != nil {
|
||||
in, out := &in.Conditions, &out.Conditions
|
||||
*out = make([]v1.Condition, len(*in))
|
||||
for i := range *in {
|
||||
(*in)[i].DeepCopyInto(&(*out)[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new JobStatus.
|
||||
func (in *JobStatus) DeepCopy() *JobStatus {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(JobStatus)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *Velero) DeepCopyInto(out *Velero) {
|
||||
*out = *in
|
||||
out.TypeMeta = in.TypeMeta
|
||||
in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
|
||||
in.Spec.DeepCopyInto(&out.Spec)
|
||||
in.Status.DeepCopyInto(&out.Status)
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Velero.
|
||||
func (in *Velero) DeepCopy() *Velero {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(Velero)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
|
||||
func (in *Velero) DeepCopyObject() runtime.Object {
|
||||
if c := in.DeepCopy(); c != nil {
|
||||
return c
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *VeleroList) DeepCopyInto(out *VeleroList) {
|
||||
*out = *in
|
||||
out.TypeMeta = in.TypeMeta
|
||||
in.ListMeta.DeepCopyInto(&out.ListMeta)
|
||||
if in.Items != nil {
|
||||
in, out := &in.Items, &out.Items
|
||||
*out = make([]Velero, len(*in))
|
||||
for i := range *in {
|
||||
(*in)[i].DeepCopyInto(&(*out)[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VeleroList.
|
||||
func (in *VeleroList) DeepCopy() *VeleroList {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(VeleroList)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
|
||||
func (in *VeleroList) DeepCopyObject() runtime.Object {
|
||||
if c := in.DeepCopy(); c != nil {
|
||||
return c
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *VeleroSpec) DeepCopyInto(out *VeleroSpec) {
|
||||
*out = *in
|
||||
in.Template.DeepCopyInto(&out.Template)
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VeleroSpec.
|
||||
func (in *VeleroSpec) DeepCopy() *VeleroSpec {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(VeleroSpec)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *VeleroStatus) DeepCopyInto(out *VeleroStatus) {
|
||||
*out = *in
|
||||
if in.Conditions != nil {
|
||||
in, out := &in.Conditions, &out.Conditions
|
||||
*out = make([]v1.Condition, len(*in))
|
||||
for i := range *in {
|
||||
(*in)[i].DeepCopyInto(&(*out)[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VeleroStatus.
|
||||
func (in *VeleroStatus) DeepCopy() *VeleroStatus {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(VeleroStatus)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *VeleroTemplate) DeepCopyInto(out *VeleroTemplate) {
|
||||
*out = *in
|
||||
in.Spec.DeepCopyInto(&out.Spec)
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VeleroTemplate.
|
||||
func (in *VeleroTemplate) DeepCopy() *VeleroTemplate {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(VeleroTemplate)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
421
api/backups/v1alpha1/DESIGN.md
Normal file
421
api/backups/v1alpha1/DESIGN.md
Normal file
@@ -0,0 +1,421 @@
|
||||
# Cozystack Backups – Core API & Contracts (Draft)
|
||||
|
||||
## 1. Overview
|
||||
|
||||
Cozystack’s backup subsystem provides a generic, composable way to back up and restore managed applications:
|
||||
|
||||
* Every **application instance** can have one or more **backup plans**.
|
||||
* Backups are stored in configurable **storage locations**.
|
||||
* The mechanics of *how* a backup/restore is performed are delegated to **strategy drivers**, each implementing driver-specific **BackupStrategy** CRDs.
|
||||
|
||||
The core API:
|
||||
|
||||
* Orchestrates **when** backups happen and **where** they’re stored.
|
||||
* Tracks **what** backups exist and their status.
|
||||
* Defines contracts with drivers via shared resources (`BackupJob`, `Backup`, `RestoreJob`).
|
||||
|
||||
It does **not** implement the backup logic itself.
|
||||
|
||||
This document covers only the **core** API and its contracts with drivers, not driver implementations.
|
||||
|
||||
---
|
||||
|
||||
## 2. Goals and non-goals
|
||||
|
||||
### Goals
|
||||
|
||||
* Provide a **stable core API** for:
|
||||
|
||||
* Declaring **backup plans** per application.
|
||||
* Configuring **storage targets** (S3, in-cluster bucket, etc.).
|
||||
* Tracking **backup artifacts**.
|
||||
* Initiating and tracking **restores**.
|
||||
* Allow multiple **strategy drivers** to plug in, each supporting specific kinds of applications and strategies.
|
||||
* Let application/product authors implement backup for their kinds by:
|
||||
|
||||
* Creating **Plan** objects referencing a **driver-specific strategy**.
|
||||
* Not having to write a backup engine themselves.
|
||||
|
||||
### Non-goals
|
||||
|
||||
* Implement backup logic for any specific application or storage backend.
|
||||
* Define the internal structure of driver-specific strategy CRDs.
|
||||
* Handle tenant-facing UI/UX (that’s built on top of these APIs).
|
||||
|
||||
---
|
||||
|
||||
## 3. Architecture
|
||||
|
||||
High-level components:
|
||||
|
||||
* **Core backups controller(s)** (Cozystack-owned):
|
||||
|
||||
* Group: `backups.cozystack.io`
|
||||
* Own:
|
||||
|
||||
* `Plan`
|
||||
* `BackupJob`
|
||||
* `Backup`
|
||||
* `RestoreJob`
|
||||
* Responsibilities:
|
||||
|
||||
* Schedule backups based on `Plan`.
|
||||
* Create `BackupJob` objects when due.
|
||||
* Provide stable contracts for drivers to:
|
||||
|
||||
* Perform backups and create `Backup`s.
|
||||
* Perform restores based on `Backup`s.
|
||||
|
||||
* **Strategy drivers** (pluggable, possibly third-party):
|
||||
|
||||
* Their own API groups, e.g. `jobdriver.backups.cozystack.io`.
|
||||
* Own **strategy CRDs** (e.g. `JobBackupStrategy`).
|
||||
* Implement controllers that:
|
||||
|
||||
* Watch `BackupJob` / `RestoreJob`.
|
||||
* Match runs whose `strategyRef` GVK they support.
|
||||
* Execute backup/restore logic.
|
||||
* Create and update `Backup` and run statuses.
|
||||
|
||||
Strategy drivers and core communicate entirely via Kubernetes objects; there are no webhook/HTTP calls between them.
|
||||
|
||||
* **Storage drivers** (pluggable, possibly third-party):
|
||||
|
||||
* **TBD**
|
||||
|
||||
---
|
||||
|
||||
## 4. Core API resources
|
||||
|
||||
### 4.1 Plan
|
||||
|
||||
**Group/Kind**
|
||||
`backups.cozystack.io/v1alpha1, Kind=Plan`
|
||||
|
||||
**Purpose**
|
||||
Describe **when**, **how**, and **where** to back up a specific managed application.
|
||||
|
||||
**Key fields (spec)**
|
||||
|
||||
```go
|
||||
type PlanSpec struct {
|
||||
// Application to back up.
|
||||
ApplicationRef corev1.TypedLocalObjectReference `json:"applicationRef"`
|
||||
|
||||
// Where backups should be stored.
|
||||
StorageRef corev1.TypedLocalObjectReference `json:"storageRef"`
|
||||
|
||||
// Driver-specific BackupStrategy to use.
|
||||
StrategyRef corev1.TypedLocalObjectReference `json:"strategyRef"`
|
||||
|
||||
// When backups should run.
|
||||
Schedule PlanSchedule `json:"schedule"`
|
||||
}
|
||||
```
|
||||
|
||||
`PlanSchedule` (initially) supports only cron:
|
||||
|
||||
```go
|
||||
type PlanScheduleType string
|
||||
|
||||
const (
|
||||
PlanScheduleTypeEmpty PlanScheduleType = ""
|
||||
PlanScheduleTypeCron PlanScheduleType = "cron"
|
||||
)
|
||||
```
|
||||
|
||||
```go
|
||||
type PlanSchedule struct {
|
||||
// Type is the schedule type. Currently only "cron" is supported.
|
||||
// Defaults to "cron".
|
||||
Type PlanScheduleType `json:"type,omitempty"`
|
||||
|
||||
// Cron expression (required for cron type).
|
||||
Cron string `json:"cron,omitempty"`
|
||||
}
|
||||
```
|
||||
|
||||
**Plan reconciliation contract**
|
||||
|
||||
Core Plan controller:
|
||||
|
||||
1. **Read schedule** from `spec.schedule` and compute the next fire time.
|
||||
2. When due:
|
||||
|
||||
* Create a `BackupJob` in the same namespace:
|
||||
|
||||
* `spec.planRef.name = plan.Name`
|
||||
* `spec.applicationRef = plan.spec.applicationRef`
|
||||
* `spec.storageRef = plan.spec.storageRef`
|
||||
* `spec.strategyRef = plan.spec.strategyRef`
|
||||
* `spec.triggeredBy = "Plan"`
|
||||
* Set `ownerReferences` so the `BackupJob` is owned by the `Plan`.
|
||||
|
||||
The Plan controller does **not**:
|
||||
|
||||
* Execute backups itself.
|
||||
* Modify driver resources or `Backup` objects.
|
||||
* Touch `BackupJob.spec` after creation.
|
||||
|
||||
---
|
||||
|
||||
### 4.2 Storage
|
||||
|
||||
**API Shape**
|
||||
|
||||
TBD
|
||||
|
||||
**Storage usage**
|
||||
|
||||
* `Plan` and `BackupJob` reference `Storage` via `TypedLocalObjectReference`.
|
||||
* Drivers read `Storage` to know how/where to store or read artifacts.
|
||||
* Core treats `Storage` spec as opaque; it does not directly talk to S3 or buckets.
|
||||
|
||||
---
|
||||
|
||||
### 4.3 BackupJob
|
||||
|
||||
**Group/Kind**
|
||||
`backups.cozystack.io/v1alpha1, Kind=BackupJob`
|
||||
|
||||
**Purpose**
|
||||
Represent a single **execution** of a backup operation, typically created when a `Plan` fires or when a user triggers an ad-hoc backup.
|
||||
|
||||
**Key fields (spec)**
|
||||
|
||||
```go
|
||||
type BackupJobSpec struct {
|
||||
// Plan that triggered this run, if any.
|
||||
PlanRef *corev1.LocalObjectReference `json:"planRef,omitempty"`
|
||||
|
||||
// Application to back up.
|
||||
ApplicationRef corev1.TypedLocalObjectReference `json:"applicationRef"`
|
||||
|
||||
// Storage to use.
|
||||
StorageRef corev1.TypedLocalObjectReference `json:"storageRef"`
|
||||
|
||||
// Driver-specific BackupStrategy to use.
|
||||
StrategyRef corev1.TypedLocalObjectReference `json:"strategyRef"`
|
||||
|
||||
// Informational: what triggered this run ("Plan", "Manual", etc.).
|
||||
TriggeredBy string `json:"triggeredBy,omitempty"`
|
||||
}
|
||||
```
|
||||
|
||||
**Key fields (status)**
|
||||
|
||||
```go
|
||||
type BackupJobStatus struct {
|
||||
Phase BackupJobPhase `json:"phase,omitempty"`
|
||||
BackupRef *corev1.LocalObjectReference `json:"backupRef,omitempty"`
|
||||
StartedAt *metav1.Time `json:"startedAt,omitempty"`
|
||||
CompletedAt *metav1.Time `json:"completedAt,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Conditions []metav1.Condition `json:"conditions,omitempty"`
|
||||
}
|
||||
```
|
||||
|
||||
`BackupJobPhase` is one of: `Pending`, `Running`, `Succeeded`, `Failed`.
|
||||
|
||||
**BackupJob contract with drivers**
|
||||
|
||||
* Core **creates** `BackupJob` and must treat `spec` as immutable afterwards.
|
||||
* Each driver controller:
|
||||
|
||||
* Watches `BackupJob`.
|
||||
* Reconciles runs where `spec.strategyRef.apiGroup/kind` matches its **strategy type(s)**.
|
||||
* Driver responsibilities:
|
||||
|
||||
1. On first reconcile:
|
||||
|
||||
* Set `status.startedAt` if unset.
|
||||
* Set `status.phase = Running`.
|
||||
2. Resolve inputs:
|
||||
|
||||
* Read `Strategy` (driver-owned CRD), `Storage`, `Application`, optionally `Plan`.
|
||||
3. Execute backup logic (implementation-specific).
|
||||
4. On success:
|
||||
|
||||
* Create a `Backup` resource (see below).
|
||||
* Set `status.backupRef` to the created `Backup`.
|
||||
* Set `status.completedAt`.
|
||||
* Set `status.phase = Succeeded`.
|
||||
5. On failure:
|
||||
|
||||
* Set `status.completedAt`.
|
||||
* Set `status.phase = Failed`.
|
||||
* Set `status.message` and conditions.
|
||||
|
||||
Drivers must **not** modify `BackupJob.spec` or delete `BackupJob` themselves.
|
||||
|
||||
---
|
||||
|
||||
### 4.4 Backup
|
||||
|
||||
**Group/Kind**
|
||||
`backups.cozystack.io/v1alpha1, Kind=Backup`
|
||||
|
||||
**Purpose**
|
||||
Represent a single **backup artifact** for a given application, decoupled from a particular run. usable as a stable, listable “thing you can restore from”.
|
||||
|
||||
**Key fields (spec)**
|
||||
|
||||
```go
|
||||
type BackupSpec struct {
|
||||
ApplicationRef corev1.TypedLocalObjectReference `json:"applicationRef"`
|
||||
PlanRef *corev1.LocalObjectReference `json:"planRef,omitempty"`
|
||||
StorageRef corev1.TypedLocalObjectReference `json:"storageRef"`
|
||||
StrategyRef corev1.TypedLocalObjectReference `json:"strategyRef"`
|
||||
TakenAt metav1.Time `json:"takenAt"`
|
||||
DriverMetadata map[string]string `json:"driverMetadata,omitempty"`
|
||||
}
|
||||
```
|
||||
|
||||
**Key fields (status)**
|
||||
|
||||
```go
|
||||
type BackupStatus struct {
|
||||
Phase BackupPhase `json:"phase,omitempty"` // Pending, Ready, Failed, etc.
|
||||
Artifact *BackupArtifact `json:"artifact,omitempty"`
|
||||
Conditions []metav1.Condition `json:"conditions,omitempty"`
|
||||
}
|
||||
```
|
||||
|
||||
`BackupArtifact` describes the artifact (URI, size, checksum).
|
||||
|
||||
**Backup contract with drivers**
|
||||
|
||||
* On successful completion of a `BackupJob`, the **driver**:
|
||||
|
||||
* Creates a `Backup` in the same namespace (typically owned by the `BackupJob`).
|
||||
* Populates `spec` fields with:
|
||||
|
||||
* The application, storage, strategy references.
|
||||
* `takenAt`.
|
||||
* Optional `driverMetadata`.
|
||||
* Sets `status` with:
|
||||
|
||||
* `phase = Ready` (or equivalent when fully usable).
|
||||
* `artifact` describing the stored object.
|
||||
* Core:
|
||||
|
||||
* Treats `Backup` spec as mostly immutable and opaque.
|
||||
* Uses it to:
|
||||
|
||||
* List backups for a given application/plan.
|
||||
* Anchor `RestoreJob` operations.
|
||||
* Implement higher-level policies (retention) if needed.
|
||||
|
||||
---
|
||||
|
||||
### 4.5 RestoreJob
|
||||
|
||||
**Group/Kind**
|
||||
`backups.cozystack.io/v1alpha1, Kind=RestoreJob`
|
||||
|
||||
**Purpose**
|
||||
Represent a single **restore operation** from a `Backup`, either back into the same application or into a new target application.
|
||||
|
||||
**Key fields (spec)**
|
||||
|
||||
```go
|
||||
type RestoreJobSpec struct {
|
||||
// Backup to restore from.
|
||||
BackupRef corev1.LocalObjectReference `json:"backupRef"`
|
||||
|
||||
// Target application; if omitted, drivers SHOULD restore into
|
||||
// backup.spec.applicationRef.
|
||||
TargetApplicationRef *corev1.TypedLocalObjectReference `json:"targetApplicationRef,omitempty"`
|
||||
}
|
||||
```
|
||||
|
||||
**Key fields (status)**
|
||||
|
||||
```go
|
||||
type RestoreJobStatus struct {
|
||||
Phase RestoreJobPhase `json:"phase,omitempty"` // Pending, Running, Succeeded, Failed
|
||||
StartedAt *metav1.Time `json:"startedAt,omitempty"`
|
||||
CompletedAt *metav1.Time `json:"completedAt,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Conditions []metav1.Condition `json:"conditions,omitempty"`
|
||||
}
|
||||
```
|
||||
|
||||
**RestoreJob contract with drivers**
|
||||
|
||||
* RestoreJob is created either manually or by core.
|
||||
* Driver controller:
|
||||
|
||||
1. Watches `RestoreJob`.
|
||||
2. On reconcile:
|
||||
|
||||
* Fetches the referenced `Backup`.
|
||||
* Determines effective:
|
||||
|
||||
* **Strategy**: `backup.spec.strategyRef`.
|
||||
* **Storage**: `backup.spec.storageRef`.
|
||||
* **Target application**: `spec.targetApplicationRef` or `backup.spec.applicationRef`.
|
||||
* If effective strategy’s GVK is one of its supported strategy types → driver is responsible.
|
||||
3. Behaviour:
|
||||
|
||||
* On first reconcile, set `status.startedAt` and `phase = Running`.
|
||||
* Resolve `Backup`, `Storage`, `Strategy`, target application.
|
||||
* Execute restore logic (implementation-specific).
|
||||
* On success:
|
||||
|
||||
* Set `status.completedAt`.
|
||||
* Set `status.phase = Succeeded`.
|
||||
* On failure:
|
||||
|
||||
* Set `status.completedAt`.
|
||||
* Set `status.phase = Failed`.
|
||||
* Set `status.message` and conditions.
|
||||
|
||||
Drivers must not modify `RestoreJob.spec` or delete `RestoreJob`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Strategy drivers (high-level)
|
||||
|
||||
Strategy drivers are separate controllers that:
|
||||
|
||||
* Define their own **strategy CRDs** (e.g. `JobBackupStrategy`) in their own API groups:
|
||||
|
||||
* e.g. `jobdriver.backups.cozystack.io/v1alpha1, Kind=JobBackupStrategy`
|
||||
* Implement the **BackupJob contract**:
|
||||
|
||||
* Watch `BackupJob`.
|
||||
* Filter by `spec.strategyRef.apiGroup/kind`.
|
||||
* Execute backup logic.
|
||||
* Create/update `Backup`.
|
||||
* Implement the **RestoreJob contract**:
|
||||
|
||||
* Watch `RestoreJob`.
|
||||
* Resolve `Backup`, then effective `strategyRef`.
|
||||
* Filter by effective strategy GVK.
|
||||
* Execute restore logic.
|
||||
|
||||
The core backups API **does not** dictate:
|
||||
|
||||
* The fields and structure of driver strategy specs.
|
||||
* How drivers implement backup/restore internally (Jobs, snapshots, native operator CRDs, etc.).
|
||||
|
||||
Drivers are interchangeable as long as they respect:
|
||||
|
||||
* The `BackupJob` and `RestoreJob` contracts.
|
||||
* The shapes and semantics of `Backup` objects.
|
||||
|
||||
---
|
||||
|
||||
## 6. Summary
|
||||
|
||||
The Cozystack backups core API:
|
||||
|
||||
* Uses a single group, `backups.cozystack.io`, for all core CRDs.
|
||||
* Cleanly separates:
|
||||
|
||||
* **When & where** (Plan + Storage) – core-owned.
|
||||
* **What backup artifacts exist** (Backup) – driver-created but cluster-visible.
|
||||
* **Execution lifecycle** (BackupJob, RestoreJob) – shared contract boundary.
|
||||
* Allows multiple strategy drivers to implement backup/restore logic without entangling their implementation with the core API.
|
||||
|
||||
118
api/backups/v1alpha1/backup_types.go
Normal file
118
api/backups/v1alpha1/backup_types.go
Normal file
@@ -0,0 +1,118 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// Package v1alpha1 defines backups.cozystack.io API types.
|
||||
//
|
||||
// Group: backups.cozystack.io
|
||||
// Version: v1alpha1
|
||||
package v1alpha1
|
||||
|
||||
import (
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
)
|
||||
|
||||
func init() {
|
||||
SchemeBuilder.Register(func(s *runtime.Scheme) error {
|
||||
s.AddKnownTypes(GroupVersion,
|
||||
&Backup{},
|
||||
&BackupList{},
|
||||
)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// BackupPhase represents the lifecycle phase of a Backup.
|
||||
type BackupPhase string
|
||||
|
||||
const (
|
||||
BackupPhaseEmpty BackupPhase = ""
|
||||
BackupPhasePending BackupPhase = "Pending"
|
||||
BackupPhaseReady BackupPhase = "Ready"
|
||||
BackupPhaseFailed BackupPhase = "Failed"
|
||||
)
|
||||
|
||||
// BackupArtifact describes the stored backup object (tarball, snapshot, etc.).
|
||||
type BackupArtifact struct {
|
||||
// URI is a driver-/storage-specific URI pointing to the backup artifact.
|
||||
// For example: s3://bucket/prefix/file.tar.gz
|
||||
URI string `json:"uri"`
|
||||
|
||||
// SizeBytes is the size of the artifact in bytes, if known.
|
||||
// +optional
|
||||
SizeBytes int64 `json:"sizeBytes,omitempty"`
|
||||
|
||||
// Checksum is the checksum of the artifact, if computed.
|
||||
// For example: "sha256:<hex>".
|
||||
// +optional
|
||||
Checksum string `json:"checksum,omitempty"`
|
||||
}
|
||||
|
||||
// BackupSpec describes an immutable backup artifact produced by a BackupJob.
|
||||
type BackupSpec struct {
|
||||
// ApplicationRef refers to the application that was backed up.
|
||||
ApplicationRef corev1.TypedLocalObjectReference `json:"applicationRef"`
|
||||
|
||||
// PlanRef refers to the Plan that produced this backup, if any.
|
||||
// For manually triggered backups, this can be omitted.
|
||||
// +optional
|
||||
PlanRef *corev1.LocalObjectReference `json:"planRef,omitempty"`
|
||||
|
||||
// StorageRef refers to the Storage object that describes where the backup
|
||||
// artifact is stored.
|
||||
StorageRef corev1.TypedLocalObjectReference `json:"storageRef"`
|
||||
|
||||
// StrategyRef refers to the driver-specific BackupStrategy that was used
|
||||
// to create this backup. This allows the driver to later perform restores.
|
||||
StrategyRef corev1.TypedLocalObjectReference `json:"strategyRef"`
|
||||
|
||||
// TakenAt is the time at which the backup was taken (as reported by the
|
||||
// driver). It may differ slightly from metadata.creationTimestamp.
|
||||
TakenAt metav1.Time `json:"takenAt"`
|
||||
|
||||
// DriverMetadata holds driver-specific, opaque metadata associated with
|
||||
// this backup (for example snapshot IDs, schema versions, etc.).
|
||||
// This data is not interpreted by the core backup controllers.
|
||||
// +optional
|
||||
DriverMetadata map[string]string `json:"driverMetadata,omitempty"`
|
||||
}
|
||||
|
||||
// BackupStatus represents the observed state of a Backup.
|
||||
type BackupStatus struct {
|
||||
// Phase is a simple, high-level summary of the backup's state.
|
||||
// Typical values are: Pending, Ready, Failed.
|
||||
// +optional
|
||||
Phase BackupPhase `json:"phase,omitempty"`
|
||||
|
||||
// Artifact describes the stored backup object, if available.
|
||||
// +optional
|
||||
Artifact *BackupArtifact `json:"artifact,omitempty"`
|
||||
|
||||
// Conditions represents the latest available observations of a Backup's state.
|
||||
// +optional
|
||||
Conditions []metav1.Condition `json:"conditions,omitempty"`
|
||||
}
|
||||
|
||||
// The field indexing on applicationRef will be needed later to display per-app backup resources.
|
||||
|
||||
// +kubebuilder:object:root=true
|
||||
// +kubebuilder:selectablefield:JSONPath=`.spec.applicationRef.apiGroup`
|
||||
// +kubebuilder:selectablefield:JSONPath=`.spec.applicationRef.kind`
|
||||
// +kubebuilder:selectablefield:JSONPath=`.spec.applicationRef.name`
|
||||
|
||||
// Backup represents a single backup artifact for a given application.
|
||||
type Backup struct {
|
||||
metav1.TypeMeta `json:",inline"`
|
||||
metav1.ObjectMeta `json:"metadata,omitempty"`
|
||||
|
||||
Spec BackupSpec `json:"spec,omitempty"`
|
||||
Status BackupStatus `json:"status,omitempty"`
|
||||
}
|
||||
|
||||
// +kubebuilder:object:root=true
|
||||
|
||||
// BackupList contains a list of Backups.
|
||||
type BackupList struct {
|
||||
metav1.TypeMeta `json:",inline"`
|
||||
metav1.ListMeta `json:"metadata,omitempty"`
|
||||
Items []Backup `json:"items"`
|
||||
}
|
||||
116
api/backups/v1alpha1/backupjob_types.go
Normal file
116
api/backups/v1alpha1/backupjob_types.go
Normal file
@@ -0,0 +1,116 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// Package v1alpha1 defines backups.cozystack.io API types.
|
||||
//
|
||||
// Group: backups.cozystack.io
|
||||
// Version: v1alpha1
|
||||
package v1alpha1
|
||||
|
||||
import (
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
)
|
||||
|
||||
func init() {
|
||||
SchemeBuilder.Register(func(s *runtime.Scheme) error {
|
||||
s.AddKnownTypes(GroupVersion,
|
||||
&BackupJob{},
|
||||
&BackupJobList{},
|
||||
)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
const (
|
||||
OwningJobNameLabel = thisGroup + "/owned-by.BackupJobName"
|
||||
OwningJobNamespaceLabel = thisGroup + "/owned-by.BackupJobNamespace"
|
||||
)
|
||||
|
||||
// BackupJobPhase represents the lifecycle phase of a BackupJob.
|
||||
type BackupJobPhase string
|
||||
|
||||
const (
|
||||
BackupJobPhaseEmpty BackupJobPhase = ""
|
||||
BackupJobPhasePending BackupJobPhase = "Pending"
|
||||
BackupJobPhaseRunning BackupJobPhase = "Running"
|
||||
BackupJobPhaseSucceeded BackupJobPhase = "Succeeded"
|
||||
BackupJobPhaseFailed BackupJobPhase = "Failed"
|
||||
)
|
||||
|
||||
// BackupJobSpec describes the execution of a single backup operation.
|
||||
type BackupJobSpec struct {
|
||||
// PlanRef refers to the Plan that requested this backup run.
|
||||
// For ad-hoc/manual backups, this can be omitted.
|
||||
// +optional
|
||||
PlanRef *corev1.LocalObjectReference `json:"planRef,omitempty"`
|
||||
|
||||
// ApplicationRef holds a reference to the managed application whose state
|
||||
// is being backed up.
|
||||
ApplicationRef corev1.TypedLocalObjectReference `json:"applicationRef"`
|
||||
|
||||
// StorageRef holds a reference to the Storage object that describes where
|
||||
// the backup will be stored.
|
||||
StorageRef corev1.TypedLocalObjectReference `json:"storageRef"`
|
||||
|
||||
// StrategyRef holds a reference to the driver-specific BackupStrategy object
|
||||
// that describes how the backup should be created.
|
||||
StrategyRef corev1.TypedLocalObjectReference `json:"strategyRef"`
|
||||
}
|
||||
|
||||
// BackupJobStatus represents the observed state of a BackupJob.
|
||||
type BackupJobStatus struct {
|
||||
// Phase is a high-level summary of the run's state.
|
||||
// Typical values: Pending, Running, Succeeded, Failed.
|
||||
// +optional
|
||||
Phase BackupJobPhase `json:"phase,omitempty"`
|
||||
|
||||
// BackupRef refers to the Backup object created by this run, if any.
|
||||
// +optional
|
||||
BackupRef *corev1.LocalObjectReference `json:"backupRef,omitempty"`
|
||||
|
||||
// StartedAt is the time at which the backup run started.
|
||||
// +optional
|
||||
StartedAt *metav1.Time `json:"startedAt,omitempty"`
|
||||
|
||||
// CompletedAt is the time at which the backup run completed (successfully
|
||||
// or otherwise).
|
||||
// +optional
|
||||
CompletedAt *metav1.Time `json:"completedAt,omitempty"`
|
||||
|
||||
// Message is a human-readable message indicating details about why the
|
||||
// backup run is in its current phase, if any.
|
||||
// +optional
|
||||
Message string `json:"message,omitempty"`
|
||||
|
||||
// Conditions represents the latest available observations of a BackupJob's state.
|
||||
// +optional
|
||||
Conditions []metav1.Condition `json:"conditions,omitempty"`
|
||||
}
|
||||
|
||||
// The field indexing on applicationRef will be needed later to display per-app backup resources.
|
||||
|
||||
// +kubebuilder:object:root=true
|
||||
// +kubebuilder:subresource:status
|
||||
// +kubebuilder:printcolumn:name="Phase",type="string",JSONPath=".status.phase",priority=0
|
||||
// +kubebuilder:selectablefield:JSONPath=`.spec.applicationRef.apiGroup`
|
||||
// +kubebuilder:selectablefield:JSONPath=`.spec.applicationRef.kind`
|
||||
// +kubebuilder:selectablefield:JSONPath=`.spec.applicationRef.name`
|
||||
|
||||
// BackupJob represents a single execution of a backup.
|
||||
// It is typically created by a Plan controller when a schedule fires.
|
||||
type BackupJob struct {
|
||||
metav1.TypeMeta `json:",inline"`
|
||||
metav1.ObjectMeta `json:"metadata,omitempty"`
|
||||
|
||||
Spec BackupJobSpec `json:"spec,omitempty"`
|
||||
Status BackupJobStatus `json:"status,omitempty"`
|
||||
}
|
||||
|
||||
// +kubebuilder:object:root=true
|
||||
|
||||
// BackupJobList contains a list of BackupJobs.
|
||||
type BackupJobList struct {
|
||||
metav1.TypeMeta `json:",inline"`
|
||||
metav1.ListMeta `json:"metadata,omitempty"`
|
||||
Items []BackupJob `json:"items"`
|
||||
}
|
||||
42
api/backups/v1alpha1/groupversion_info.go
Normal file
42
api/backups/v1alpha1/groupversion_info.go
Normal file
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
Copyright 2025.
|
||||
|
||||
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 v1alpha1 contains API Schema definitions for the v1alpha1 API group.
|
||||
// +kubebuilder:object:generate=true
|
||||
// +groupName=backups.cozystack.io
|
||||
package v1alpha1
|
||||
|
||||
import (
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
)
|
||||
|
||||
const (
|
||||
thisGroup = "backups.cozystack.io"
|
||||
thisVersion = "v1alpha1"
|
||||
)
|
||||
|
||||
var (
|
||||
GroupVersion = schema.GroupVersion{Group: thisGroup, Version: thisVersion}
|
||||
SchemeBuilder = runtime.NewSchemeBuilder(addGroupVersion)
|
||||
AddToScheme = SchemeBuilder.AddToScheme
|
||||
)
|
||||
|
||||
func addGroupVersion(scheme *runtime.Scheme) error {
|
||||
metav1.AddToGroupVersion(scheme, GroupVersion)
|
||||
return nil
|
||||
}
|
||||
98
api/backups/v1alpha1/plan_types.go
Normal file
98
api/backups/v1alpha1/plan_types.go
Normal file
@@ -0,0 +1,98 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// Package v1alpha1 defines backups.cozystack.io API types.
|
||||
//
|
||||
// Group: backups.cozystack.io
|
||||
// Version: v1alpha1
|
||||
package v1alpha1
|
||||
|
||||
import (
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
)
|
||||
|
||||
func init() {
|
||||
SchemeBuilder.Register(func(s *runtime.Scheme) error {
|
||||
s.AddKnownTypes(GroupVersion,
|
||||
&Plan{},
|
||||
&PlanList{},
|
||||
)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
type PlanScheduleType string
|
||||
|
||||
const (
|
||||
PlanScheduleTypeEmpty PlanScheduleType = ""
|
||||
PlanScheduleTypeCron PlanScheduleType = "cron"
|
||||
)
|
||||
|
||||
// Condtions
|
||||
const (
|
||||
PlanConditionError = "Error"
|
||||
)
|
||||
|
||||
// The field indexing on applicationRef will be needed later to display per-app backup resources.
|
||||
|
||||
// +kubebuilder:object:root=true
|
||||
// +kubebuilder:subresource:status
|
||||
// +kubebuilder:selectablefield:JSONPath=`.spec.applicationRef.apiGroup`
|
||||
// +kubebuilder:selectablefield:JSONPath=`.spec.applicationRef.kind`
|
||||
// +kubebuilder:selectablefield:JSONPath=`.spec.applicationRef.name`
|
||||
|
||||
// Plan describes the schedule, method and storage location for the
|
||||
// backup of a given target application.
|
||||
type Plan struct {
|
||||
metav1.TypeMeta `json:",inline"`
|
||||
metav1.ObjectMeta `json:"metadata,omitempty"`
|
||||
|
||||
Spec PlanSpec `json:"spec,omitempty"`
|
||||
Status PlanStatus `json:"status,omitempty"`
|
||||
}
|
||||
|
||||
// +kubebuilder:object:root=true
|
||||
|
||||
// PlanList contains a list of backup Plans.
|
||||
type PlanList struct {
|
||||
metav1.TypeMeta `json:",inline"`
|
||||
metav1.ListMeta `json:"metadata,omitempty"`
|
||||
Items []Plan `json:"items"`
|
||||
}
|
||||
|
||||
// PlanSpec references the storage, the strategy, the application to be
|
||||
// backed up and specifies the timetable on which the backups will run.
|
||||
type PlanSpec struct {
|
||||
// ApplicationRef holds a reference to the managed application,
|
||||
// whose state and configuration must be backed up.
|
||||
ApplicationRef corev1.TypedLocalObjectReference `json:"applicationRef"`
|
||||
|
||||
// StorageRef holds a reference to the Storage object that
|
||||
// describes the location where the backup will be stored.
|
||||
StorageRef corev1.TypedLocalObjectReference `json:"storageRef"`
|
||||
|
||||
// StrategyRef holds a reference to the Strategy object that
|
||||
// describes, how a backup copy is to be created.
|
||||
StrategyRef corev1.TypedLocalObjectReference `json:"strategyRef"`
|
||||
|
||||
// Schedule specifies when backup copies are created.
|
||||
Schedule PlanSchedule `json:"schedule"`
|
||||
}
|
||||
|
||||
// PlanSchedule specifies when backup copies are created.
|
||||
type PlanSchedule struct {
|
||||
// Type is the type of schedule specification. Supported values are
|
||||
// [`cron`]. If omitted, defaults to `cron`.
|
||||
// +optional
|
||||
Type PlanScheduleType `json:"type,omitempty"`
|
||||
|
||||
// Cron contains the cron spec for scheduling backups. Must be
|
||||
// specified if the schedule type is `cron`. Since only `cron` is
|
||||
// supported, omitting this field is not allowed.
|
||||
// +optional
|
||||
Cron string `json:"cron,omitempty"`
|
||||
}
|
||||
|
||||
type PlanStatus struct {
|
||||
Conditions []metav1.Condition `json:"conditions,omitempty"`
|
||||
}
|
||||
91
api/backups/v1alpha1/restorejob_types.go
Normal file
91
api/backups/v1alpha1/restorejob_types.go
Normal file
@@ -0,0 +1,91 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// Package v1alpha1 defines backups.cozystack.io API types.
|
||||
//
|
||||
// Group: backups.cozystack.io
|
||||
// Version: v1alpha1
|
||||
package v1alpha1
|
||||
|
||||
import (
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
)
|
||||
|
||||
func init() {
|
||||
SchemeBuilder.Register(func(s *runtime.Scheme) error {
|
||||
s.AddKnownTypes(GroupVersion,
|
||||
&RestoreJob{},
|
||||
&RestoreJobList{},
|
||||
)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// RestoreJobPhase represents the lifecycle phase of a RestoreJob.
|
||||
type RestoreJobPhase string
|
||||
|
||||
const (
|
||||
RestoreJobPhaseEmpty RestoreJobPhase = ""
|
||||
RestoreJobPhasePending RestoreJobPhase = "Pending"
|
||||
RestoreJobPhaseRunning RestoreJobPhase = "Running"
|
||||
RestoreJobPhaseSucceeded RestoreJobPhase = "Succeeded"
|
||||
RestoreJobPhaseFailed RestoreJobPhase = "Failed"
|
||||
)
|
||||
|
||||
// RestoreJobSpec describes the execution of a single restore operation.
|
||||
type RestoreJobSpec struct {
|
||||
// BackupRef refers to the Backup that should be restored.
|
||||
BackupRef corev1.LocalObjectReference `json:"backupRef"`
|
||||
|
||||
// TargetApplicationRef refers to the application into which the backup
|
||||
// should be restored. If omitted, the driver SHOULD restore into the same
|
||||
// application as referenced by backup.spec.applicationRef.
|
||||
// +optional
|
||||
TargetApplicationRef *corev1.TypedLocalObjectReference `json:"targetApplicationRef,omitempty"`
|
||||
}
|
||||
|
||||
// RestoreJobStatus represents the observed state of a RestoreJob.
|
||||
type RestoreJobStatus struct {
|
||||
// Phase is a high-level summary of the run's state.
|
||||
// Typical values: Pending, Running, Succeeded, Failed.
|
||||
// +optional
|
||||
Phase RestoreJobPhase `json:"phase,omitempty"`
|
||||
|
||||
// StartedAt is the time at which the restore run started.
|
||||
// +optional
|
||||
StartedAt *metav1.Time `json:"startedAt,omitempty"`
|
||||
|
||||
// CompletedAt is the time at which the restore run completed (successfully
|
||||
// or otherwise).
|
||||
// +optional
|
||||
CompletedAt *metav1.Time `json:"completedAt,omitempty"`
|
||||
|
||||
// Message is a human-readable message indicating details about why the
|
||||
// restore run is in its current phase, if any.
|
||||
// +optional
|
||||
Message string `json:"message,omitempty"`
|
||||
|
||||
// Conditions represents the latest available observations of a RestoreJob's state.
|
||||
// +optional
|
||||
Conditions []metav1.Condition `json:"conditions,omitempty"`
|
||||
}
|
||||
|
||||
// +kubebuilder:object:root=true
|
||||
|
||||
// RestoreJob represents a single execution of a restore from a Backup.
|
||||
type RestoreJob struct {
|
||||
metav1.TypeMeta `json:",inline"`
|
||||
metav1.ObjectMeta `json:"metadata,omitempty"`
|
||||
|
||||
Spec RestoreJobSpec `json:"spec,omitempty"`
|
||||
Status RestoreJobStatus `json:"status,omitempty"`
|
||||
}
|
||||
|
||||
// +kubebuilder:object:root=true
|
||||
|
||||
// RestoreJobList contains a list of RestoreJobs.
|
||||
type RestoreJobList struct {
|
||||
metav1.TypeMeta `json:",inline"`
|
||||
metav1.ListMeta `json:"metadata,omitempty"`
|
||||
Items []RestoreJob `json:"items"`
|
||||
}
|
||||
501
api/backups/v1alpha1/zz_generated.deepcopy.go
Normal file
501
api/backups/v1alpha1/zz_generated.deepcopy.go
Normal file
@@ -0,0 +1,501 @@
|
||||
//go:build !ignore_autogenerated
|
||||
|
||||
/*
|
||||
Copyright 2025 The Cozystack 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.
|
||||
*/
|
||||
|
||||
// Code generated by controller-gen. DO NOT EDIT.
|
||||
|
||||
package v1alpha1
|
||||
|
||||
import (
|
||||
"k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
)
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *Backup) DeepCopyInto(out *Backup) {
|
||||
*out = *in
|
||||
out.TypeMeta = in.TypeMeta
|
||||
in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
|
||||
in.Spec.DeepCopyInto(&out.Spec)
|
||||
in.Status.DeepCopyInto(&out.Status)
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Backup.
|
||||
func (in *Backup) DeepCopy() *Backup {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(Backup)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
|
||||
func (in *Backup) DeepCopyObject() runtime.Object {
|
||||
if c := in.DeepCopy(); c != nil {
|
||||
return c
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *BackupArtifact) DeepCopyInto(out *BackupArtifact) {
|
||||
*out = *in
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BackupArtifact.
|
||||
func (in *BackupArtifact) DeepCopy() *BackupArtifact {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(BackupArtifact)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *BackupJob) DeepCopyInto(out *BackupJob) {
|
||||
*out = *in
|
||||
out.TypeMeta = in.TypeMeta
|
||||
in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
|
||||
in.Spec.DeepCopyInto(&out.Spec)
|
||||
in.Status.DeepCopyInto(&out.Status)
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BackupJob.
|
||||
func (in *BackupJob) DeepCopy() *BackupJob {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(BackupJob)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
|
||||
func (in *BackupJob) DeepCopyObject() runtime.Object {
|
||||
if c := in.DeepCopy(); c != nil {
|
||||
return c
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *BackupJobList) DeepCopyInto(out *BackupJobList) {
|
||||
*out = *in
|
||||
out.TypeMeta = in.TypeMeta
|
||||
in.ListMeta.DeepCopyInto(&out.ListMeta)
|
||||
if in.Items != nil {
|
||||
in, out := &in.Items, &out.Items
|
||||
*out = make([]BackupJob, len(*in))
|
||||
for i := range *in {
|
||||
(*in)[i].DeepCopyInto(&(*out)[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BackupJobList.
|
||||
func (in *BackupJobList) DeepCopy() *BackupJobList {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(BackupJobList)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
|
||||
func (in *BackupJobList) DeepCopyObject() runtime.Object {
|
||||
if c := in.DeepCopy(); c != nil {
|
||||
return c
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *BackupJobSpec) DeepCopyInto(out *BackupJobSpec) {
|
||||
*out = *in
|
||||
if in.PlanRef != nil {
|
||||
in, out := &in.PlanRef, &out.PlanRef
|
||||
*out = new(v1.LocalObjectReference)
|
||||
**out = **in
|
||||
}
|
||||
in.ApplicationRef.DeepCopyInto(&out.ApplicationRef)
|
||||
in.StorageRef.DeepCopyInto(&out.StorageRef)
|
||||
in.StrategyRef.DeepCopyInto(&out.StrategyRef)
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BackupJobSpec.
|
||||
func (in *BackupJobSpec) DeepCopy() *BackupJobSpec {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(BackupJobSpec)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *BackupJobStatus) DeepCopyInto(out *BackupJobStatus) {
|
||||
*out = *in
|
||||
if in.BackupRef != nil {
|
||||
in, out := &in.BackupRef, &out.BackupRef
|
||||
*out = new(v1.LocalObjectReference)
|
||||
**out = **in
|
||||
}
|
||||
if in.StartedAt != nil {
|
||||
in, out := &in.StartedAt, &out.StartedAt
|
||||
*out = (*in).DeepCopy()
|
||||
}
|
||||
if in.CompletedAt != nil {
|
||||
in, out := &in.CompletedAt, &out.CompletedAt
|
||||
*out = (*in).DeepCopy()
|
||||
}
|
||||
if in.Conditions != nil {
|
||||
in, out := &in.Conditions, &out.Conditions
|
||||
*out = make([]metav1.Condition, len(*in))
|
||||
for i := range *in {
|
||||
(*in)[i].DeepCopyInto(&(*out)[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BackupJobStatus.
|
||||
func (in *BackupJobStatus) DeepCopy() *BackupJobStatus {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(BackupJobStatus)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *BackupList) DeepCopyInto(out *BackupList) {
|
||||
*out = *in
|
||||
out.TypeMeta = in.TypeMeta
|
||||
in.ListMeta.DeepCopyInto(&out.ListMeta)
|
||||
if in.Items != nil {
|
||||
in, out := &in.Items, &out.Items
|
||||
*out = make([]Backup, len(*in))
|
||||
for i := range *in {
|
||||
(*in)[i].DeepCopyInto(&(*out)[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BackupList.
|
||||
func (in *BackupList) DeepCopy() *BackupList {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(BackupList)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
|
||||
func (in *BackupList) DeepCopyObject() runtime.Object {
|
||||
if c := in.DeepCopy(); c != nil {
|
||||
return c
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *BackupSpec) DeepCopyInto(out *BackupSpec) {
|
||||
*out = *in
|
||||
in.ApplicationRef.DeepCopyInto(&out.ApplicationRef)
|
||||
if in.PlanRef != nil {
|
||||
in, out := &in.PlanRef, &out.PlanRef
|
||||
*out = new(v1.LocalObjectReference)
|
||||
**out = **in
|
||||
}
|
||||
in.StorageRef.DeepCopyInto(&out.StorageRef)
|
||||
in.StrategyRef.DeepCopyInto(&out.StrategyRef)
|
||||
in.TakenAt.DeepCopyInto(&out.TakenAt)
|
||||
if in.DriverMetadata != nil {
|
||||
in, out := &in.DriverMetadata, &out.DriverMetadata
|
||||
*out = make(map[string]string, len(*in))
|
||||
for key, val := range *in {
|
||||
(*out)[key] = val
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BackupSpec.
|
||||
func (in *BackupSpec) DeepCopy() *BackupSpec {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(BackupSpec)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *BackupStatus) DeepCopyInto(out *BackupStatus) {
|
||||
*out = *in
|
||||
if in.Artifact != nil {
|
||||
in, out := &in.Artifact, &out.Artifact
|
||||
*out = new(BackupArtifact)
|
||||
**out = **in
|
||||
}
|
||||
if in.Conditions != nil {
|
||||
in, out := &in.Conditions, &out.Conditions
|
||||
*out = make([]metav1.Condition, len(*in))
|
||||
for i := range *in {
|
||||
(*in)[i].DeepCopyInto(&(*out)[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BackupStatus.
|
||||
func (in *BackupStatus) DeepCopy() *BackupStatus {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(BackupStatus)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *Plan) DeepCopyInto(out *Plan) {
|
||||
*out = *in
|
||||
out.TypeMeta = in.TypeMeta
|
||||
in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
|
||||
in.Spec.DeepCopyInto(&out.Spec)
|
||||
in.Status.DeepCopyInto(&out.Status)
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Plan.
|
||||
func (in *Plan) DeepCopy() *Plan {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(Plan)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
|
||||
func (in *Plan) DeepCopyObject() runtime.Object {
|
||||
if c := in.DeepCopy(); c != nil {
|
||||
return c
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *PlanList) DeepCopyInto(out *PlanList) {
|
||||
*out = *in
|
||||
out.TypeMeta = in.TypeMeta
|
||||
in.ListMeta.DeepCopyInto(&out.ListMeta)
|
||||
if in.Items != nil {
|
||||
in, out := &in.Items, &out.Items
|
||||
*out = make([]Plan, len(*in))
|
||||
for i := range *in {
|
||||
(*in)[i].DeepCopyInto(&(*out)[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PlanList.
|
||||
func (in *PlanList) DeepCopy() *PlanList {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(PlanList)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
|
||||
func (in *PlanList) DeepCopyObject() runtime.Object {
|
||||
if c := in.DeepCopy(); c != nil {
|
||||
return c
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *PlanSchedule) DeepCopyInto(out *PlanSchedule) {
|
||||
*out = *in
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PlanSchedule.
|
||||
func (in *PlanSchedule) DeepCopy() *PlanSchedule {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(PlanSchedule)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *PlanSpec) DeepCopyInto(out *PlanSpec) {
|
||||
*out = *in
|
||||
in.ApplicationRef.DeepCopyInto(&out.ApplicationRef)
|
||||
in.StorageRef.DeepCopyInto(&out.StorageRef)
|
||||
in.StrategyRef.DeepCopyInto(&out.StrategyRef)
|
||||
out.Schedule = in.Schedule
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PlanSpec.
|
||||
func (in *PlanSpec) DeepCopy() *PlanSpec {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(PlanSpec)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *PlanStatus) DeepCopyInto(out *PlanStatus) {
|
||||
*out = *in
|
||||
if in.Conditions != nil {
|
||||
in, out := &in.Conditions, &out.Conditions
|
||||
*out = make([]metav1.Condition, len(*in))
|
||||
for i := range *in {
|
||||
(*in)[i].DeepCopyInto(&(*out)[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PlanStatus.
|
||||
func (in *PlanStatus) DeepCopy() *PlanStatus {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(PlanStatus)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *RestoreJob) DeepCopyInto(out *RestoreJob) {
|
||||
*out = *in
|
||||
out.TypeMeta = in.TypeMeta
|
||||
in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
|
||||
in.Spec.DeepCopyInto(&out.Spec)
|
||||
in.Status.DeepCopyInto(&out.Status)
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RestoreJob.
|
||||
func (in *RestoreJob) DeepCopy() *RestoreJob {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(RestoreJob)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
|
||||
func (in *RestoreJob) DeepCopyObject() runtime.Object {
|
||||
if c := in.DeepCopy(); c != nil {
|
||||
return c
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *RestoreJobList) DeepCopyInto(out *RestoreJobList) {
|
||||
*out = *in
|
||||
out.TypeMeta = in.TypeMeta
|
||||
in.ListMeta.DeepCopyInto(&out.ListMeta)
|
||||
if in.Items != nil {
|
||||
in, out := &in.Items, &out.Items
|
||||
*out = make([]RestoreJob, len(*in))
|
||||
for i := range *in {
|
||||
(*in)[i].DeepCopyInto(&(*out)[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RestoreJobList.
|
||||
func (in *RestoreJobList) DeepCopy() *RestoreJobList {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(RestoreJobList)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
|
||||
func (in *RestoreJobList) DeepCopyObject() runtime.Object {
|
||||
if c := in.DeepCopy(); c != nil {
|
||||
return c
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *RestoreJobSpec) DeepCopyInto(out *RestoreJobSpec) {
|
||||
*out = *in
|
||||
out.BackupRef = in.BackupRef
|
||||
if in.TargetApplicationRef != nil {
|
||||
in, out := &in.TargetApplicationRef, &out.TargetApplicationRef
|
||||
*out = new(v1.TypedLocalObjectReference)
|
||||
(*in).DeepCopyInto(*out)
|
||||
}
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RestoreJobSpec.
|
||||
func (in *RestoreJobSpec) DeepCopy() *RestoreJobSpec {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(RestoreJobSpec)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *RestoreJobStatus) DeepCopyInto(out *RestoreJobStatus) {
|
||||
*out = *in
|
||||
if in.StartedAt != nil {
|
||||
in, out := &in.StartedAt, &out.StartedAt
|
||||
*out = (*in).DeepCopy()
|
||||
}
|
||||
if in.CompletedAt != nil {
|
||||
in, out := &in.CompletedAt, &out.CompletedAt
|
||||
*out = (*in).DeepCopy()
|
||||
}
|
||||
if in.Conditions != nil {
|
||||
in, out := &in.Conditions, &out.Conditions
|
||||
*out = make([]metav1.Condition, len(*in))
|
||||
for i := range *in {
|
||||
(*in)[i].DeepCopyInto(&(*out)[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RestoreJobStatus.
|
||||
func (in *RestoreJobStatus) DeepCopy() *RestoreJobStatus {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(RestoreJobStatus)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
@@ -17,6 +17,7 @@ limitations under the License.
|
||||
package v1alpha1
|
||||
|
||||
import (
|
||||
helmv2 "github.com/fluxcd/helm-controller/api/v2"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
)
|
||||
|
||||
@@ -61,24 +62,6 @@ type CozystackResourceDefinitionSpec struct {
|
||||
Dashboard *CozystackResourceDefinitionDashboard `json:"dashboard,omitempty"`
|
||||
}
|
||||
|
||||
type CozystackResourceDefinitionChart struct {
|
||||
// Name of the Helm chart
|
||||
Name string `json:"name"`
|
||||
// Source reference for the Helm chart
|
||||
SourceRef SourceRef `json:"sourceRef"`
|
||||
}
|
||||
|
||||
type SourceRef struct {
|
||||
// Kind of the source reference
|
||||
// +kubebuilder:default:="HelmRepository"
|
||||
Kind string `json:"kind"`
|
||||
// Name of the source reference
|
||||
Name string `json:"name"`
|
||||
// Namespace of the source reference
|
||||
// +kubebuilder:default:="cozy-public"
|
||||
Namespace string `json:"namespace"`
|
||||
}
|
||||
|
||||
type CozystackResourceDefinitionApplication struct {
|
||||
// Kind of the application, used for UI and API
|
||||
Kind string `json:"kind"`
|
||||
@@ -91,8 +74,8 @@ type CozystackResourceDefinitionApplication struct {
|
||||
}
|
||||
|
||||
type CozystackResourceDefinitionRelease struct {
|
||||
// Helm chart configuration
|
||||
Chart CozystackResourceDefinitionChart `json:"chart"`
|
||||
// Reference to the chart source
|
||||
ChartRef *helmv2.CrossNamespaceSourceReference `json:"chartRef"`
|
||||
// Labels for the release
|
||||
Labels map[string]string `json:"labels,omitempty"`
|
||||
// Prefix for the release name
|
||||
@@ -110,17 +93,18 @@ type CozystackResourceDefinitionRelease struct {
|
||||
// - {{ .namespace }}: The namespace of the resource being processed
|
||||
//
|
||||
// Example YAML:
|
||||
// secrets:
|
||||
// include:
|
||||
// - matchExpressions:
|
||||
// - key: badlabel
|
||||
// operator: DoesNotExist
|
||||
// matchLabels:
|
||||
// goodlabel: goodvalue
|
||||
// resourceNames:
|
||||
// - "{{ .name }}-secret"
|
||||
// - "{{ .kind }}-{{ .name }}-tls"
|
||||
// - "specificname"
|
||||
//
|
||||
// secrets:
|
||||
// include:
|
||||
// - matchExpressions:
|
||||
// - key: badlabel
|
||||
// operator: DoesNotExist
|
||||
// matchLabels:
|
||||
// goodlabel: goodvalue
|
||||
// resourceNames:
|
||||
// - "{{ .name }}-secret"
|
||||
// - "{{ .kind }}-{{ .name }}-tls"
|
||||
// - "specificname"
|
||||
type CozystackResourceDefinitionResourceSelector struct {
|
||||
metav1.LabelSelector `json:",inline"`
|
||||
// ResourceNames is a list of resource names to match
|
||||
|
||||
100
api/v1alpha1/package_types.go
Normal file
100
api/v1alpha1/package_types.go
Normal file
@@ -0,0 +1,100 @@
|
||||
/*
|
||||
Copyright 2025.
|
||||
|
||||
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 v1alpha1
|
||||
|
||||
import (
|
||||
apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
)
|
||||
|
||||
// +kubebuilder:object:root=true
|
||||
// +kubebuilder:resource:scope=Cluster,shortName={pkg,pkgs}
|
||||
// +kubebuilder:subresource:status
|
||||
// +kubebuilder:printcolumn:name="Variant",type="string",JSONPath=".spec.variant",description="Selected variant"
|
||||
// +kubebuilder:printcolumn:name="Ready",type="string",JSONPath=".status.conditions[?(@.type=='Ready')].status",description="Ready status"
|
||||
// +kubebuilder:printcolumn:name="Status",type="string",JSONPath=".status.conditions[?(@.type=='Ready')].message",description="Ready message"
|
||||
|
||||
// Package is the Schema for the packages API
|
||||
type Package struct {
|
||||
metav1.TypeMeta `json:",inline"`
|
||||
metav1.ObjectMeta `json:"metadata,omitempty"`
|
||||
|
||||
Spec PackageSpec `json:"spec,omitempty"`
|
||||
Status PackageStatus `json:"status,omitempty"`
|
||||
}
|
||||
|
||||
// +kubebuilder:object:root=true
|
||||
|
||||
// PackageList contains a list of Packages
|
||||
type PackageList struct {
|
||||
metav1.TypeMeta `json:",inline"`
|
||||
metav1.ListMeta `json:"metadata,omitempty"`
|
||||
Items []Package `json:"items"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
SchemeBuilder.Register(&Package{}, &PackageList{})
|
||||
}
|
||||
|
||||
// PackageSpec defines the desired state of Package
|
||||
type PackageSpec struct {
|
||||
// Variant is the name of the variant to use from the PackageSource
|
||||
// If not specified, defaults to "default"
|
||||
// +optional
|
||||
Variant string `json:"variant,omitempty"`
|
||||
|
||||
// IgnoreDependencies is a list of package source dependencies to ignore
|
||||
// Dependencies listed here will not be installed even if they are specified in the PackageSource
|
||||
// +optional
|
||||
IgnoreDependencies []string `json:"ignoreDependencies,omitempty"`
|
||||
|
||||
// Components is a map of release name to component overrides
|
||||
// Allows overriding values and enabling/disabling specific components from the PackageSource
|
||||
// +optional
|
||||
Components map[string]PackageComponent `json:"components,omitempty"`
|
||||
}
|
||||
|
||||
// PackageComponent defines overrides for a specific component
|
||||
type PackageComponent struct {
|
||||
// Enabled indicates whether this component should be installed
|
||||
// If false, the component will be disabled even if it's defined in the PackageSource
|
||||
// +optional
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
|
||||
// Values contains Helm chart values as a JSON object
|
||||
// These values will be merged with the default values from the PackageSource
|
||||
// +optional
|
||||
Values *apiextensionsv1.JSON `json:"values,omitempty"`
|
||||
}
|
||||
|
||||
// PackageStatus defines the observed state of Package
|
||||
type PackageStatus struct {
|
||||
// Conditions represents the latest available observations of a Package's state
|
||||
// +optional
|
||||
Conditions []metav1.Condition `json:"conditions,omitempty"`
|
||||
|
||||
// Dependencies tracks the readiness status of each dependency
|
||||
// Key is the dependency package name, value indicates if the dependency is ready
|
||||
// +optional
|
||||
Dependencies map[string]DependencyStatus `json:"dependencies,omitempty"`
|
||||
}
|
||||
|
||||
// DependencyStatus represents the readiness status of a dependency
|
||||
type DependencyStatus struct {
|
||||
// Ready indicates whether the dependency is ready
|
||||
Ready bool `json:"ready"`
|
||||
}
|
||||
171
api/v1alpha1/packagesource_types.go
Normal file
171
api/v1alpha1/packagesource_types.go
Normal file
@@ -0,0 +1,171 @@
|
||||
/*
|
||||
Copyright 2025.
|
||||
|
||||
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 v1alpha1
|
||||
|
||||
import (
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
)
|
||||
|
||||
// +kubebuilder:object:root=true
|
||||
// +kubebuilder:resource:scope=Cluster,shortName={pks}
|
||||
// +kubebuilder:subresource:status
|
||||
// +kubebuilder:printcolumn:name="Variants",type="string",JSONPath=".status.variants",description="Package variants (comma-separated)"
|
||||
// +kubebuilder:printcolumn:name="Ready",type="string",JSONPath=".status.conditions[?(@.type=='Ready')].status",description="Ready status"
|
||||
// +kubebuilder:printcolumn:name="Status",type="string",JSONPath=".status.conditions[?(@.type=='Ready')].message",description="Ready message"
|
||||
|
||||
// PackageSource is the Schema for the packagesources API
|
||||
type PackageSource struct {
|
||||
metav1.TypeMeta `json:",inline"`
|
||||
metav1.ObjectMeta `json:"metadata,omitempty"`
|
||||
|
||||
Spec PackageSourceSpec `json:"spec,omitempty"`
|
||||
Status PackageSourceStatus `json:"status,omitempty"`
|
||||
}
|
||||
|
||||
// +kubebuilder:object:root=true
|
||||
|
||||
// PackageSourceList contains a list of PackageSources
|
||||
type PackageSourceList struct {
|
||||
metav1.TypeMeta `json:",inline"`
|
||||
metav1.ListMeta `json:"metadata,omitempty"`
|
||||
Items []PackageSource `json:"items"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
SchemeBuilder.Register(&PackageSource{}, &PackageSourceList{})
|
||||
}
|
||||
|
||||
// PackageSourceSpec defines the desired state of PackageSource
|
||||
type PackageSourceSpec struct {
|
||||
// SourceRef is the source reference for the package source charts
|
||||
// +optional
|
||||
SourceRef *PackageSourceRef `json:"sourceRef,omitempty"`
|
||||
|
||||
// Variants is a list of package source variants
|
||||
// Each variant defines components, applications, dependencies, and libraries for a specific configuration
|
||||
// +optional
|
||||
Variants []Variant `json:"variants,omitempty"`
|
||||
}
|
||||
|
||||
// Variant defines a single variant configuration
|
||||
type Variant struct {
|
||||
// Name is the unique identifier for this variant
|
||||
// +required
|
||||
Name string `json:"name"`
|
||||
|
||||
// DependsOn is a list of package source dependencies
|
||||
// For example: "cozystack.networking"
|
||||
// +optional
|
||||
DependsOn []string `json:"dependsOn,omitempty"`
|
||||
|
||||
// Libraries is a list of Helm library charts used by components in this variant
|
||||
// +optional
|
||||
Libraries []Library `json:"libraries,omitempty"`
|
||||
|
||||
// Components is a list of Helm releases to be installed as part of this variant
|
||||
// +optional
|
||||
Components []Component `json:"components,omitempty"`
|
||||
}
|
||||
|
||||
// Library defines a Helm library chart
|
||||
type Library struct {
|
||||
// Name is the optional name for library placed in charts
|
||||
// +optional
|
||||
Name string `json:"name,omitempty"`
|
||||
|
||||
// Path is the path to the library chart directory
|
||||
// +required
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
// PackageSourceRef defines the source reference for package source charts
|
||||
type PackageSourceRef struct {
|
||||
// Kind of the source reference
|
||||
// +kubebuilder:validation:Enum=GitRepository;OCIRepository
|
||||
// +required
|
||||
Kind string `json:"kind"`
|
||||
|
||||
// Name of the source reference
|
||||
// +required
|
||||
Name string `json:"name"`
|
||||
|
||||
// Namespace of the source reference
|
||||
// +required
|
||||
Namespace string `json:"namespace"`
|
||||
|
||||
// Path is the base path where packages are located in the source.
|
||||
// For GitRepository, defaults to "packages" if not specified.
|
||||
// For OCIRepository, defaults to empty string (root) if not specified.
|
||||
// +optional
|
||||
Path string `json:"path,omitempty"`
|
||||
}
|
||||
|
||||
// ComponentInstall defines installation parameters for a component
|
||||
type ComponentInstall struct {
|
||||
// ReleaseName is the name of the HelmRelease resource that will be created
|
||||
// If not specified, defaults to the component Name field
|
||||
// +optional
|
||||
ReleaseName string `json:"releaseName,omitempty"`
|
||||
|
||||
// Namespace is the Kubernetes namespace where the release will be installed
|
||||
// +optional
|
||||
Namespace string `json:"namespace,omitempty"`
|
||||
|
||||
// Privileged indicates whether this release requires privileged access
|
||||
// +optional
|
||||
Privileged bool `json:"privileged,omitempty"`
|
||||
|
||||
// DependsOn is a list of component names that must be installed before this component
|
||||
// +optional
|
||||
DependsOn []string `json:"dependsOn,omitempty"`
|
||||
}
|
||||
|
||||
// Component defines a single Helm release component within a package source
|
||||
type Component struct {
|
||||
// Name is the unique identifier for this component within the package source
|
||||
// +required
|
||||
Name string `json:"name"`
|
||||
|
||||
// Path is the path to the Helm chart directory
|
||||
// +required
|
||||
Path string `json:"path"`
|
||||
|
||||
// Install defines installation parameters for this component
|
||||
// +optional
|
||||
Install *ComponentInstall `json:"install,omitempty"`
|
||||
|
||||
// Libraries is a list of library names that this component depends on
|
||||
// These libraries must be defined at the variant level
|
||||
// +optional
|
||||
Libraries []string `json:"libraries,omitempty"`
|
||||
|
||||
// ValuesFiles is a list of values file names to use
|
||||
// +optional
|
||||
ValuesFiles []string `json:"valuesFiles,omitempty"`
|
||||
}
|
||||
|
||||
// PackageSourceStatus defines the observed state of PackageSource
|
||||
type PackageSourceStatus struct {
|
||||
// Variants is a comma-separated list of package variant names
|
||||
// This field is populated by the controller based on spec.variants keys
|
||||
// +optional
|
||||
Variants string `json:"variants,omitempty"`
|
||||
|
||||
// Conditions represents the latest available observations of a PackageSource's state
|
||||
// +optional
|
||||
Conditions []metav1.Condition `json:"conditions,omitempty"`
|
||||
}
|
||||
@@ -21,10 +21,63 @@ limitations under the License.
|
||||
package v1alpha1
|
||||
|
||||
import (
|
||||
"github.com/fluxcd/helm-controller/api/v2"
|
||||
"k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
|
||||
"k8s.io/apimachinery/pkg/api/resource"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
runtime "k8s.io/apimachinery/pkg/runtime"
|
||||
)
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *Component) DeepCopyInto(out *Component) {
|
||||
*out = *in
|
||||
if in.Install != nil {
|
||||
in, out := &in.Install, &out.Install
|
||||
*out = new(ComponentInstall)
|
||||
(*in).DeepCopyInto(*out)
|
||||
}
|
||||
if in.Libraries != nil {
|
||||
in, out := &in.Libraries, &out.Libraries
|
||||
*out = make([]string, len(*in))
|
||||
copy(*out, *in)
|
||||
}
|
||||
if in.ValuesFiles != nil {
|
||||
in, out := &in.ValuesFiles, &out.ValuesFiles
|
||||
*out = make([]string, len(*in))
|
||||
copy(*out, *in)
|
||||
}
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Component.
|
||||
func (in *Component) DeepCopy() *Component {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(Component)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *ComponentInstall) DeepCopyInto(out *ComponentInstall) {
|
||||
*out = *in
|
||||
if in.DependsOn != nil {
|
||||
in, out := &in.DependsOn, &out.DependsOn
|
||||
*out = make([]string, len(*in))
|
||||
copy(*out, *in)
|
||||
}
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ComponentInstall.
|
||||
func (in *ComponentInstall) DeepCopy() *ComponentInstall {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(ComponentInstall)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *CozystackResourceDefinition) DeepCopyInto(out *CozystackResourceDefinition) {
|
||||
*out = *in
|
||||
@@ -66,22 +119,6 @@ func (in *CozystackResourceDefinitionApplication) DeepCopy() *CozystackResourceD
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *CozystackResourceDefinitionChart) DeepCopyInto(out *CozystackResourceDefinitionChart) {
|
||||
*out = *in
|
||||
out.SourceRef = in.SourceRef
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CozystackResourceDefinitionChart.
|
||||
func (in *CozystackResourceDefinitionChart) DeepCopy() *CozystackResourceDefinitionChart {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(CozystackResourceDefinitionChart)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *CozystackResourceDefinitionDashboard) DeepCopyInto(out *CozystackResourceDefinitionDashboard) {
|
||||
*out = *in
|
||||
@@ -153,7 +190,11 @@ func (in *CozystackResourceDefinitionList) DeepCopyObject() runtime.Object {
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *CozystackResourceDefinitionRelease) DeepCopyInto(out *CozystackResourceDefinitionRelease) {
|
||||
*out = *in
|
||||
out.Chart = in.Chart
|
||||
if in.ChartRef != nil {
|
||||
in, out := &in.ChartRef, &out.ChartRef
|
||||
*out = new(v2.CrossNamespaceSourceReference)
|
||||
**out = **in
|
||||
}
|
||||
if in.Labels != nil {
|
||||
in, out := &in.Labels, &out.Labels
|
||||
*out = make(map[string]string, len(*in))
|
||||
@@ -256,6 +297,299 @@ func (in *CozystackResourceDefinitionSpec) DeepCopy() *CozystackResourceDefiniti
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *DependencyStatus) DeepCopyInto(out *DependencyStatus) {
|
||||
*out = *in
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DependencyStatus.
|
||||
func (in *DependencyStatus) DeepCopy() *DependencyStatus {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(DependencyStatus)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *Library) DeepCopyInto(out *Library) {
|
||||
*out = *in
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Library.
|
||||
func (in *Library) DeepCopy() *Library {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(Library)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *Package) DeepCopyInto(out *Package) {
|
||||
*out = *in
|
||||
out.TypeMeta = in.TypeMeta
|
||||
in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
|
||||
in.Spec.DeepCopyInto(&out.Spec)
|
||||
in.Status.DeepCopyInto(&out.Status)
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Package.
|
||||
func (in *Package) DeepCopy() *Package {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(Package)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
|
||||
func (in *Package) DeepCopyObject() runtime.Object {
|
||||
if c := in.DeepCopy(); c != nil {
|
||||
return c
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *PackageComponent) DeepCopyInto(out *PackageComponent) {
|
||||
*out = *in
|
||||
if in.Enabled != nil {
|
||||
in, out := &in.Enabled, &out.Enabled
|
||||
*out = new(bool)
|
||||
**out = **in
|
||||
}
|
||||
if in.Values != nil {
|
||||
in, out := &in.Values, &out.Values
|
||||
*out = new(v1.JSON)
|
||||
(*in).DeepCopyInto(*out)
|
||||
}
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PackageComponent.
|
||||
func (in *PackageComponent) DeepCopy() *PackageComponent {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(PackageComponent)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *PackageList) DeepCopyInto(out *PackageList) {
|
||||
*out = *in
|
||||
out.TypeMeta = in.TypeMeta
|
||||
in.ListMeta.DeepCopyInto(&out.ListMeta)
|
||||
if in.Items != nil {
|
||||
in, out := &in.Items, &out.Items
|
||||
*out = make([]Package, len(*in))
|
||||
for i := range *in {
|
||||
(*in)[i].DeepCopyInto(&(*out)[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PackageList.
|
||||
func (in *PackageList) DeepCopy() *PackageList {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(PackageList)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
|
||||
func (in *PackageList) DeepCopyObject() runtime.Object {
|
||||
if c := in.DeepCopy(); c != nil {
|
||||
return c
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *PackageSource) DeepCopyInto(out *PackageSource) {
|
||||
*out = *in
|
||||
out.TypeMeta = in.TypeMeta
|
||||
in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
|
||||
in.Spec.DeepCopyInto(&out.Spec)
|
||||
in.Status.DeepCopyInto(&out.Status)
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PackageSource.
|
||||
func (in *PackageSource) DeepCopy() *PackageSource {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(PackageSource)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
|
||||
func (in *PackageSource) DeepCopyObject() runtime.Object {
|
||||
if c := in.DeepCopy(); c != nil {
|
||||
return c
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *PackageSourceList) DeepCopyInto(out *PackageSourceList) {
|
||||
*out = *in
|
||||
out.TypeMeta = in.TypeMeta
|
||||
in.ListMeta.DeepCopyInto(&out.ListMeta)
|
||||
if in.Items != nil {
|
||||
in, out := &in.Items, &out.Items
|
||||
*out = make([]PackageSource, len(*in))
|
||||
for i := range *in {
|
||||
(*in)[i].DeepCopyInto(&(*out)[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PackageSourceList.
|
||||
func (in *PackageSourceList) DeepCopy() *PackageSourceList {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(PackageSourceList)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
|
||||
func (in *PackageSourceList) DeepCopyObject() runtime.Object {
|
||||
if c := in.DeepCopy(); c != nil {
|
||||
return c
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *PackageSourceRef) DeepCopyInto(out *PackageSourceRef) {
|
||||
*out = *in
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PackageSourceRef.
|
||||
func (in *PackageSourceRef) DeepCopy() *PackageSourceRef {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(PackageSourceRef)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *PackageSourceSpec) DeepCopyInto(out *PackageSourceSpec) {
|
||||
*out = *in
|
||||
if in.SourceRef != nil {
|
||||
in, out := &in.SourceRef, &out.SourceRef
|
||||
*out = new(PackageSourceRef)
|
||||
**out = **in
|
||||
}
|
||||
if in.Variants != nil {
|
||||
in, out := &in.Variants, &out.Variants
|
||||
*out = make([]Variant, len(*in))
|
||||
for i := range *in {
|
||||
(*in)[i].DeepCopyInto(&(*out)[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PackageSourceSpec.
|
||||
func (in *PackageSourceSpec) DeepCopy() *PackageSourceSpec {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(PackageSourceSpec)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *PackageSourceStatus) DeepCopyInto(out *PackageSourceStatus) {
|
||||
*out = *in
|
||||
if in.Conditions != nil {
|
||||
in, out := &in.Conditions, &out.Conditions
|
||||
*out = make([]metav1.Condition, len(*in))
|
||||
for i := range *in {
|
||||
(*in)[i].DeepCopyInto(&(*out)[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PackageSourceStatus.
|
||||
func (in *PackageSourceStatus) DeepCopy() *PackageSourceStatus {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(PackageSourceStatus)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *PackageSpec) DeepCopyInto(out *PackageSpec) {
|
||||
*out = *in
|
||||
if in.IgnoreDependencies != nil {
|
||||
in, out := &in.IgnoreDependencies, &out.IgnoreDependencies
|
||||
*out = make([]string, len(*in))
|
||||
copy(*out, *in)
|
||||
}
|
||||
if in.Components != nil {
|
||||
in, out := &in.Components, &out.Components
|
||||
*out = make(map[string]PackageComponent, len(*in))
|
||||
for key, val := range *in {
|
||||
(*out)[key] = *val.DeepCopy()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PackageSpec.
|
||||
func (in *PackageSpec) DeepCopy() *PackageSpec {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(PackageSpec)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *PackageStatus) DeepCopyInto(out *PackageStatus) {
|
||||
*out = *in
|
||||
if in.Conditions != nil {
|
||||
in, out := &in.Conditions, &out.Conditions
|
||||
*out = make([]metav1.Condition, len(*in))
|
||||
for i := range *in {
|
||||
(*in)[i].DeepCopyInto(&(*out)[i])
|
||||
}
|
||||
}
|
||||
if in.Dependencies != nil {
|
||||
in, out := &in.Dependencies, &out.Dependencies
|
||||
*out = make(map[string]DependencyStatus, len(*in))
|
||||
for key, val := range *in {
|
||||
(*out)[key] = val
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PackageStatus.
|
||||
func (in *PackageStatus) DeepCopy() *PackageStatus {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(PackageStatus)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in Selector) DeepCopyInto(out *Selector) {
|
||||
{
|
||||
@@ -278,16 +612,33 @@ func (in Selector) DeepCopy() Selector {
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *SourceRef) DeepCopyInto(out *SourceRef) {
|
||||
func (in *Variant) DeepCopyInto(out *Variant) {
|
||||
*out = *in
|
||||
if in.DependsOn != nil {
|
||||
in, out := &in.DependsOn, &out.DependsOn
|
||||
*out = make([]string, len(*in))
|
||||
copy(*out, *in)
|
||||
}
|
||||
if in.Libraries != nil {
|
||||
in, out := &in.Libraries, &out.Libraries
|
||||
*out = make([]Library, len(*in))
|
||||
copy(*out, *in)
|
||||
}
|
||||
if in.Components != nil {
|
||||
in, out := &in.Components, &out.Components
|
||||
*out = make([]Component, len(*in))
|
||||
for i := range *in {
|
||||
(*in)[i].DeepCopyInto(&(*out)[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SourceRef.
|
||||
func (in *SourceRef) DeepCopy() *SourceRef {
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Variant.
|
||||
func (in *Variant) DeepCopy() *Variant {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(SourceRef)
|
||||
out := new(Variant)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
187
cmd/backup-controller/main.go
Normal file
187
cmd/backup-controller/main.go
Normal file
@@ -0,0 +1,187 @@
|
||||
/*
|
||||
Copyright 2025.
|
||||
|
||||
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 (
|
||||
"crypto/tls"
|
||||
"flag"
|
||||
"os"
|
||||
|
||||
// Import all Kubernetes client auth plugins (e.g. Azure, GCP, OIDC, etc.)
|
||||
// to ensure that exec-entrypoint and run can make use of them.
|
||||
_ "k8s.io/client-go/plugin/pkg/client/auth"
|
||||
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
utilruntime "k8s.io/apimachinery/pkg/util/runtime"
|
||||
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
|
||||
ctrl "sigs.k8s.io/controller-runtime"
|
||||
"sigs.k8s.io/controller-runtime/pkg/healthz"
|
||||
"sigs.k8s.io/controller-runtime/pkg/log/zap"
|
||||
"sigs.k8s.io/controller-runtime/pkg/metrics/filters"
|
||||
metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server"
|
||||
"sigs.k8s.io/controller-runtime/pkg/webhook"
|
||||
|
||||
strategyv1alpha1 "github.com/cozystack/cozystack/api/backups/strategy/v1alpha1"
|
||||
backupsv1alpha1 "github.com/cozystack/cozystack/api/backups/v1alpha1"
|
||||
"github.com/cozystack/cozystack/internal/backupcontroller"
|
||||
velerov1 "github.com/vmware-tanzu/velero/pkg/apis/velero/v1"
|
||||
// +kubebuilder:scaffold:imports
|
||||
)
|
||||
|
||||
var (
|
||||
scheme = runtime.NewScheme()
|
||||
setupLog = ctrl.Log.WithName("setup")
|
||||
)
|
||||
|
||||
func init() {
|
||||
utilruntime.Must(clientgoscheme.AddToScheme(scheme))
|
||||
|
||||
utilruntime.Must(backupsv1alpha1.AddToScheme(scheme))
|
||||
utilruntime.Must(strategyv1alpha1.AddToScheme(scheme))
|
||||
utilruntime.Must(velerov1.AddToScheme(scheme))
|
||||
// +kubebuilder:scaffold:scheme
|
||||
}
|
||||
|
||||
func main() {
|
||||
var metricsAddr string
|
||||
var enableLeaderElection bool
|
||||
var probeAddr string
|
||||
var secureMetrics bool
|
||||
var enableHTTP2 bool
|
||||
var tlsOpts []func(*tls.Config)
|
||||
flag.StringVar(&metricsAddr, "metrics-bind-address", "0", "The address the metrics endpoint binds to. "+
|
||||
"Use :8443 for HTTPS or :8080 for HTTP, or leave as 0 to disable the metrics service.")
|
||||
flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081", "The address the probe endpoint binds to.")
|
||||
flag.BoolVar(&enableLeaderElection, "leader-elect", false,
|
||||
"Enable leader election for controller manager. "+
|
||||
"Enabling this will ensure there is only one active controller manager.")
|
||||
flag.BoolVar(&secureMetrics, "metrics-secure", true,
|
||||
"If set, the metrics endpoint is served securely via HTTPS. Use --metrics-secure=false to use HTTP instead.")
|
||||
flag.BoolVar(&enableHTTP2, "enable-http2", false,
|
||||
"If set, HTTP/2 will be enabled for the metrics and webhook servers")
|
||||
opts := zap.Options{
|
||||
Development: false,
|
||||
}
|
||||
opts.BindFlags(flag.CommandLine)
|
||||
flag.Parse()
|
||||
|
||||
ctrl.SetLogger(zap.New(zap.UseFlagOptions(&opts)))
|
||||
|
||||
// if the enable-http2 flag is false (the default), http/2 should be disabled
|
||||
// due to its vulnerabilities. More specifically, disabling http/2 will
|
||||
// prevent from being vulnerable to the HTTP/2 Stream Cancellation and
|
||||
// Rapid Reset CVEs. For more information see:
|
||||
// - https://github.com/advisories/GHSA-qppj-fm5r-hxr3
|
||||
// - https://github.com/advisories/GHSA-4374-p667-p6c8
|
||||
disableHTTP2 := func(c *tls.Config) {
|
||||
setupLog.Info("disabling http/2")
|
||||
c.NextProtos = []string{"http/1.1"}
|
||||
}
|
||||
|
||||
if !enableHTTP2 {
|
||||
tlsOpts = append(tlsOpts, disableHTTP2)
|
||||
}
|
||||
|
||||
webhookServer := webhook.NewServer(webhook.Options{
|
||||
TLSOpts: tlsOpts,
|
||||
})
|
||||
|
||||
// Metrics endpoint is enabled in 'config/default/kustomization.yaml'. The Metrics options configure the server.
|
||||
// More info:
|
||||
// - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.19.1/pkg/metrics/server
|
||||
// - https://book.kubebuilder.io/reference/metrics.html
|
||||
metricsServerOptions := metricsserver.Options{
|
||||
BindAddress: metricsAddr,
|
||||
SecureServing: secureMetrics,
|
||||
TLSOpts: tlsOpts,
|
||||
}
|
||||
|
||||
if secureMetrics {
|
||||
// FilterProvider is used to protect the metrics endpoint with authn/authz.
|
||||
// These configurations ensure that only authorized users and service accounts
|
||||
// can access the metrics endpoint. The RBAC are configured in 'config/rbac/kustomization.yaml'. More info:
|
||||
// https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.19.1/pkg/metrics/filters#WithAuthenticationAndAuthorization
|
||||
metricsServerOptions.FilterProvider = filters.WithAuthenticationAndAuthorization
|
||||
|
||||
// TODO(user): If CertDir, CertName, and KeyName are not specified, controller-runtime will automatically
|
||||
// generate self-signed certificates for the metrics server. While convenient for development and testing,
|
||||
// this setup is not recommended for production.
|
||||
}
|
||||
|
||||
// Configure rate limiting for the Kubernetes client
|
||||
config := ctrl.GetConfigOrDie()
|
||||
config.QPS = 50.0 // Increased from default 5.0
|
||||
config.Burst = 100 // Increased from default 10
|
||||
|
||||
mgr, err := ctrl.NewManager(config, ctrl.Options{
|
||||
Scheme: scheme,
|
||||
Metrics: metricsServerOptions,
|
||||
WebhookServer: webhookServer,
|
||||
HealthProbeBindAddress: probeAddr,
|
||||
LeaderElection: enableLeaderElection,
|
||||
LeaderElectionID: "core.backups.cozystack.io",
|
||||
// LeaderElectionReleaseOnCancel defines if the leader should step down voluntarily
|
||||
// when the Manager ends. This requires the binary to immediately end when the
|
||||
// Manager is stopped, otherwise, this setting is unsafe. Setting this significantly
|
||||
// speeds up voluntary leader transitions as the new leader don't have to wait
|
||||
// LeaseDuration time first.
|
||||
//
|
||||
// In the default scaffold provided, the program ends immediately after
|
||||
// the manager stops, so would be fine to enable this option. However,
|
||||
// if you are doing or is intended to do any operation such as perform cleanups
|
||||
// after the manager stops then its usage might be unsafe.
|
||||
// LeaderElectionReleaseOnCancel: true,
|
||||
})
|
||||
if err != nil {
|
||||
setupLog.Error(err, "unable to start manager")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if err = (&backupcontroller.PlanReconciler{
|
||||
Client: mgr.GetClient(),
|
||||
Scheme: mgr.GetScheme(),
|
||||
}).SetupWithManager(mgr); err != nil {
|
||||
setupLog.Error(err, "unable to create controller", "controller", "Plan")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if err = (&backupcontroller.BackupJobReconciler{
|
||||
Client: mgr.GetClient(),
|
||||
Scheme: mgr.GetScheme(),
|
||||
Recorder: mgr.GetEventRecorderFor("backup-controller"),
|
||||
}).SetupWithManager(mgr); err != nil {
|
||||
setupLog.Error(err, "unable to create controller", "controller", "BackupJob")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// +kubebuilder:scaffold:builder
|
||||
|
||||
if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil {
|
||||
setupLog.Error(err, "unable to set up health check")
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := mgr.AddReadyzCheck("readyz", healthz.Ping); err != nil {
|
||||
setupLog.Error(err, "unable to set up ready check")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
setupLog.Info("starting manager")
|
||||
if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil {
|
||||
setupLog.Error(err, "problem running manager")
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
174
cmd/backupstrategy-controller/main.go
Normal file
174
cmd/backupstrategy-controller/main.go
Normal file
@@ -0,0 +1,174 @@
|
||||
/*
|
||||
Copyright 2025.
|
||||
|
||||
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 (
|
||||
"crypto/tls"
|
||||
"flag"
|
||||
"os"
|
||||
|
||||
// Import all Kubernetes client auth plugins (e.g. Azure, GCP, OIDC, etc.)
|
||||
// to ensure that exec-entrypoint and run can make use of them.
|
||||
_ "k8s.io/client-go/plugin/pkg/client/auth"
|
||||
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
utilruntime "k8s.io/apimachinery/pkg/util/runtime"
|
||||
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
|
||||
ctrl "sigs.k8s.io/controller-runtime"
|
||||
"sigs.k8s.io/controller-runtime/pkg/healthz"
|
||||
"sigs.k8s.io/controller-runtime/pkg/log/zap"
|
||||
"sigs.k8s.io/controller-runtime/pkg/metrics/filters"
|
||||
metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server"
|
||||
"sigs.k8s.io/controller-runtime/pkg/webhook"
|
||||
|
||||
backupsv1alpha1 "github.com/cozystack/cozystack/api/backups/v1alpha1"
|
||||
"github.com/cozystack/cozystack/internal/backupcontroller"
|
||||
// +kubebuilder:scaffold:imports
|
||||
)
|
||||
|
||||
var (
|
||||
scheme = runtime.NewScheme()
|
||||
setupLog = ctrl.Log.WithName("setup")
|
||||
)
|
||||
|
||||
func init() {
|
||||
utilruntime.Must(clientgoscheme.AddToScheme(scheme))
|
||||
|
||||
utilruntime.Must(backupsv1alpha1.AddToScheme(scheme))
|
||||
// +kubebuilder:scaffold:scheme
|
||||
}
|
||||
|
||||
func main() {
|
||||
var metricsAddr string
|
||||
var enableLeaderElection bool
|
||||
var probeAddr string
|
||||
var secureMetrics bool
|
||||
var enableHTTP2 bool
|
||||
var tlsOpts []func(*tls.Config)
|
||||
flag.StringVar(&metricsAddr, "metrics-bind-address", "0", "The address the metrics endpoint binds to. "+
|
||||
"Use :8443 for HTTPS or :8080 for HTTP, or leave as 0 to disable the metrics service.")
|
||||
flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081", "The address the probe endpoint binds to.")
|
||||
flag.BoolVar(&enableLeaderElection, "leader-elect", false,
|
||||
"Enable leader election for controller manager. "+
|
||||
"Enabling this will ensure there is only one active controller manager.")
|
||||
flag.BoolVar(&secureMetrics, "metrics-secure", true,
|
||||
"If set, the metrics endpoint is served securely via HTTPS. Use --metrics-secure=false to use HTTP instead.")
|
||||
flag.BoolVar(&enableHTTP2, "enable-http2", false,
|
||||
"If set, HTTP/2 will be enabled for the metrics and webhook servers")
|
||||
opts := zap.Options{
|
||||
Development: false,
|
||||
}
|
||||
opts.BindFlags(flag.CommandLine)
|
||||
flag.Parse()
|
||||
|
||||
ctrl.SetLogger(zap.New(zap.UseFlagOptions(&opts)))
|
||||
|
||||
// if the enable-http2 flag is false (the default), http/2 should be disabled
|
||||
// due to its vulnerabilities. More specifically, disabling http/2 will
|
||||
// prevent from being vulnerable to the HTTP/2 Stream Cancellation and
|
||||
// Rapid Reset CVEs. For more information see:
|
||||
// - https://github.com/advisories/GHSA-qppj-fm5r-hxr3
|
||||
// - https://github.com/advisories/GHSA-4374-p667-p6c8
|
||||
disableHTTP2 := func(c *tls.Config) {
|
||||
setupLog.Info("disabling http/2")
|
||||
c.NextProtos = []string{"http/1.1"}
|
||||
}
|
||||
|
||||
if !enableHTTP2 {
|
||||
tlsOpts = append(tlsOpts, disableHTTP2)
|
||||
}
|
||||
|
||||
webhookServer := webhook.NewServer(webhook.Options{
|
||||
TLSOpts: tlsOpts,
|
||||
})
|
||||
|
||||
// Metrics endpoint is enabled in 'config/default/kustomization.yaml'. The Metrics options configure the server.
|
||||
// More info:
|
||||
// - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.19.1/pkg/metrics/server
|
||||
// - https://book.kubebuilder.io/reference/metrics.html
|
||||
metricsServerOptions := metricsserver.Options{
|
||||
BindAddress: metricsAddr,
|
||||
SecureServing: secureMetrics,
|
||||
TLSOpts: tlsOpts,
|
||||
}
|
||||
|
||||
if secureMetrics {
|
||||
// FilterProvider is used to protect the metrics endpoint with authn/authz.
|
||||
// These configurations ensure that only authorized users and service accounts
|
||||
// can access the metrics endpoint. The RBAC are configured in 'config/rbac/kustomization.yaml'. More info:
|
||||
// https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.19.1/pkg/metrics/filters#WithAuthenticationAndAuthorization
|
||||
metricsServerOptions.FilterProvider = filters.WithAuthenticationAndAuthorization
|
||||
|
||||
// TODO(user): If CertDir, CertName, and KeyName are not specified, controller-runtime will automatically
|
||||
// generate self-signed certificates for the metrics server. While convenient for development and testing,
|
||||
// this setup is not recommended for production.
|
||||
}
|
||||
|
||||
// Configure rate limiting for the Kubernetes client
|
||||
config := ctrl.GetConfigOrDie()
|
||||
config.QPS = 50.0 // Increased from default 5.0
|
||||
config.Burst = 100 // Increased from default 10
|
||||
|
||||
mgr, err := ctrl.NewManager(config, ctrl.Options{
|
||||
Scheme: scheme,
|
||||
Metrics: metricsServerOptions,
|
||||
WebhookServer: webhookServer,
|
||||
HealthProbeBindAddress: probeAddr,
|
||||
LeaderElection: enableLeaderElection,
|
||||
LeaderElectionID: "strategy.backups.cozystack.io",
|
||||
// LeaderElectionReleaseOnCancel defines if the leader should step down voluntarily
|
||||
// when the Manager ends. This requires the binary to immediately end when the
|
||||
// Manager is stopped, otherwise, this setting is unsafe. Setting this significantly
|
||||
// speeds up voluntary leader transitions as the new leader don't have to wait
|
||||
// LeaseDuration time first.
|
||||
//
|
||||
// In the default scaffold provided, the program ends immediately after
|
||||
// the manager stops, so would be fine to enable this option. However,
|
||||
// if you are doing or is intended to do any operation such as perform cleanups
|
||||
// after the manager stops then its usage might be unsafe.
|
||||
// LeaderElectionReleaseOnCancel: true,
|
||||
})
|
||||
if err != nil {
|
||||
setupLog.Error(err, "unable to start manager")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if err = (&backupcontroller.BackupJobReconciler{
|
||||
Client: mgr.GetClient(),
|
||||
Scheme: mgr.GetScheme(),
|
||||
}).SetupWithManager(mgr); err != nil {
|
||||
setupLog.Error(err, "unable to create controller", "controller", "Job")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// +kubebuilder:scaffold:builder
|
||||
|
||||
if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil {
|
||||
setupLog.Error(err, "unable to set up health check")
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := mgr.AddReadyzCheck("readyz", healthz.Ping); err != nil {
|
||||
setupLog.Error(err, "unable to set up ready check")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
setupLog.Info("starting manager")
|
||||
if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil {
|
||||
setupLog.Error(err, "problem running manager")
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
549
cmd/cozypkg/cmd/add.go
Normal file
549
cmd/cozypkg/cmd/add.go
Normal file
@@ -0,0 +1,549 @@
|
||||
/*
|
||||
Copyright 2025 The Cozystack 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 cmd
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
cozyv1alpha1 "github.com/cozystack/cozystack/api/v1alpha1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apimachinery/pkg/runtime/serializer/yaml"
|
||||
utilruntime "k8s.io/apimachinery/pkg/util/runtime"
|
||||
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
|
||||
"k8s.io/client-go/rest"
|
||||
"k8s.io/client-go/tools/clientcmd"
|
||||
ctrl "sigs.k8s.io/controller-runtime"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
_ "k8s.io/client-go/plugin/pkg/client/auth"
|
||||
)
|
||||
|
||||
var addCmdFlags struct {
|
||||
files []string
|
||||
kubeconfig string
|
||||
}
|
||||
|
||||
var addCmd = &cobra.Command{
|
||||
Use: "add [package]...",
|
||||
Short: "Install PackageSource and its dependencies interactively",
|
||||
Long: `Install PackageSource and its dependencies interactively.
|
||||
|
||||
You can specify packages as arguments or use -f flag to read from files.
|
||||
Multiple -f flags can be specified, and they can point to files or directories.`,
|
||||
Args: cobra.ArbitraryArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
|
||||
// Collect package names from arguments and files
|
||||
packageNames := make(map[string]bool)
|
||||
packagesFromFiles := make(map[string]string) // packageName -> filePath
|
||||
|
||||
for _, arg := range args {
|
||||
packageNames[arg] = true
|
||||
}
|
||||
|
||||
// Read packages from files
|
||||
for _, filePath := range addCmdFlags.files {
|
||||
packages, err := readPackagesFromFile(filePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read packages from %s: %w", filePath, err)
|
||||
}
|
||||
for _, pkg := range packages {
|
||||
packageNames[pkg] = true
|
||||
if oldPath, ok := packagesFromFiles[pkg]; ok {
|
||||
fmt.Fprintf(os.Stderr, "warning: package %q is defined in both %s and %s, using the latter\n", pkg, oldPath, filePath)
|
||||
}
|
||||
packagesFromFiles[pkg] = filePath
|
||||
}
|
||||
}
|
||||
|
||||
if len(packageNames) == 0 {
|
||||
return fmt.Errorf("no packages specified")
|
||||
}
|
||||
|
||||
// Create Kubernetes client config
|
||||
var config *rest.Config
|
||||
var err error
|
||||
|
||||
if addCmdFlags.kubeconfig != "" {
|
||||
config, err = clientcmd.BuildConfigFromFlags("", addCmdFlags.kubeconfig)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load kubeconfig from %s: %w", addCmdFlags.kubeconfig, err)
|
||||
}
|
||||
} else {
|
||||
config, err = ctrl.GetConfig()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get kubeconfig: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
scheme := runtime.NewScheme()
|
||||
utilruntime.Must(clientgoscheme.AddToScheme(scheme))
|
||||
utilruntime.Must(cozyv1alpha1.AddToScheme(scheme))
|
||||
|
||||
k8sClient, err := client.New(config, client.Options{Scheme: scheme})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create k8s client: %w", err)
|
||||
}
|
||||
|
||||
// Process each package
|
||||
for packageName := range packageNames {
|
||||
// Check if package comes from a file
|
||||
if filePath, fromFile := packagesFromFiles[packageName]; fromFile {
|
||||
// Try to create Package directly from file
|
||||
if err := createPackageFromFile(ctx, k8sClient, filePath, packageName); err == nil {
|
||||
fmt.Fprintf(os.Stderr, "✓ Added Package %s\n", packageName)
|
||||
continue
|
||||
}
|
||||
// If failed, fall back to interactive installation
|
||||
}
|
||||
|
||||
// Interactive installation from PackageSource
|
||||
if err := installPackage(ctx, k8sClient, packageName); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func readPackagesFromFile(filePath string) ([]string, error) {
|
||||
info, err := os.Stat(filePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var packages []string
|
||||
|
||||
if info.IsDir() {
|
||||
// Read all YAML files from directory
|
||||
err := filepath.Walk(filePath, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info.IsDir() || !strings.HasSuffix(path, ".yaml") && !strings.HasSuffix(path, ".yml") {
|
||||
return nil
|
||||
}
|
||||
|
||||
pkgs, err := readPackagesFromYAMLFile(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read %s: %w", path, err)
|
||||
}
|
||||
packages = append(packages, pkgs...)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
packages, err = readPackagesFromYAMLFile(filePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return packages, nil
|
||||
}
|
||||
|
||||
func readPackagesFromYAMLFile(filePath string) ([]string, error) {
|
||||
data, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var packages []string
|
||||
|
||||
// Split YAML documents (in case of multiple resources)
|
||||
documents := strings.Split(string(data), "---")
|
||||
|
||||
for _, doc := range documents {
|
||||
doc = strings.TrimSpace(doc)
|
||||
if doc == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse using Kubernetes decoder
|
||||
decoder := yaml.NewDecodingSerializer(unstructured.UnstructuredJSONScheme)
|
||||
obj := &unstructured.Unstructured{}
|
||||
_, _, err := decoder.Decode([]byte(doc), nil, obj)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if it's a Package
|
||||
if obj.GetKind() == "Package" {
|
||||
name := obj.GetName()
|
||||
if name != "" {
|
||||
packages = append(packages, name)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if it's a PackageSource
|
||||
if obj.GetKind() == "PackageSource" {
|
||||
name := obj.GetName()
|
||||
if name != "" {
|
||||
packages = append(packages, name)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Try to parse as PackageList or PackageSourceList
|
||||
if obj.GetKind() == "PackageList" || obj.GetKind() == "PackageSourceList" {
|
||||
items, found, err := unstructured.NestedSlice(obj.Object, "items")
|
||||
if err == nil && found {
|
||||
for _, item := range items {
|
||||
if itemMap, ok := item.(map[string]interface{}); ok {
|
||||
if metadata, ok := itemMap["metadata"].(map[string]interface{}); ok {
|
||||
if name, ok := metadata["name"].(string); ok && name != "" {
|
||||
packages = append(packages, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Return empty list if no packages found - don't error out
|
||||
// The check for whether any packages were specified at all is handled later in RunE
|
||||
return packages, nil
|
||||
}
|
||||
|
||||
// buildDependencyTree builds a dependency tree starting from the root PackageSource
|
||||
// Returns both the dependency tree and a map of dependencies to their requesters
|
||||
func buildDependencyTree(ctx context.Context, k8sClient client.Client, rootName string) (map[string][]string, map[string]string, error) {
|
||||
tree := make(map[string][]string)
|
||||
dependencyRequesters := make(map[string]string) // dep -> requester
|
||||
visited := make(map[string]bool)
|
||||
|
||||
// Ensure root is in tree even if it has no dependencies
|
||||
tree[rootName] = []string{}
|
||||
|
||||
var buildTree func(string) error
|
||||
buildTree = func(pkgName string) error {
|
||||
if visited[pkgName] {
|
||||
return nil
|
||||
}
|
||||
visited[pkgName] = true
|
||||
|
||||
// Get PackageSource
|
||||
ps := &cozyv1alpha1.PackageSource{}
|
||||
if err := k8sClient.Get(ctx, client.ObjectKey{Name: pkgName}, ps); err != nil {
|
||||
// If PackageSource doesn't exist, just skip it
|
||||
return nil
|
||||
}
|
||||
|
||||
// Collect all dependencies from all variants
|
||||
deps := make(map[string]bool)
|
||||
for _, variant := range ps.Spec.Variants {
|
||||
for _, dep := range variant.DependsOn {
|
||||
deps[dep] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Add dependencies to tree
|
||||
for dep := range deps {
|
||||
if _, exists := tree[pkgName]; !exists {
|
||||
tree[pkgName] = []string{}
|
||||
}
|
||||
tree[pkgName] = append(tree[pkgName], dep)
|
||||
// Track who requested this dependency
|
||||
dependencyRequesters[dep] = pkgName
|
||||
// Recursively build tree for dependencies
|
||||
if err := buildTree(dep); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := buildTree(rootName); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return tree, dependencyRequesters, nil
|
||||
}
|
||||
|
||||
// topologicalSort performs topological sort on the dependency tree
|
||||
// Returns order from root to leaves (dependencies first)
|
||||
func topologicalSort(tree map[string][]string) ([]string, error) {
|
||||
// Build reverse graph (dependencies -> dependents)
|
||||
reverseGraph := make(map[string][]string)
|
||||
allNodes := make(map[string]bool)
|
||||
|
||||
for node, deps := range tree {
|
||||
allNodes[node] = true
|
||||
for _, dep := range deps {
|
||||
allNodes[dep] = true
|
||||
reverseGraph[dep] = append(reverseGraph[dep], node)
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate in-degrees (how many dependencies a node has)
|
||||
inDegree := make(map[string]int)
|
||||
for node := range allNodes {
|
||||
inDegree[node] = 0
|
||||
}
|
||||
for node, deps := range tree {
|
||||
inDegree[node] = len(deps)
|
||||
}
|
||||
|
||||
// Kahn's algorithm - start with nodes that have no dependencies
|
||||
var queue []string
|
||||
for node, degree := range inDegree {
|
||||
if degree == 0 {
|
||||
queue = append(queue, node)
|
||||
}
|
||||
}
|
||||
|
||||
var result []string
|
||||
for len(queue) > 0 {
|
||||
node := queue[0]
|
||||
queue = queue[1:]
|
||||
result = append(result, node)
|
||||
|
||||
// Process dependents (nodes that depend on this node)
|
||||
for _, dependent := range reverseGraph[node] {
|
||||
inDegree[dependent]--
|
||||
if inDegree[dependent] == 0 {
|
||||
queue = append(queue, dependent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for cycles
|
||||
if len(result) != len(allNodes) {
|
||||
return nil, fmt.Errorf("dependency cycle detected")
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// createPackageFromFile creates a Package resource directly from a YAML file
|
||||
func createPackageFromFile(ctx context.Context, k8sClient client.Client, filePath string, packageName string) error {
|
||||
data, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Split YAML documents
|
||||
documents := strings.Split(string(data), "---")
|
||||
|
||||
for _, doc := range documents {
|
||||
doc = strings.TrimSpace(doc)
|
||||
if doc == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse using Kubernetes decoder
|
||||
decoder := yaml.NewDecodingSerializer(unstructured.UnstructuredJSONScheme)
|
||||
obj := &unstructured.Unstructured{}
|
||||
_, _, err := decoder.Decode([]byte(doc), nil, obj)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if it's a Package with matching name
|
||||
if obj.GetKind() == "Package" && obj.GetName() == packageName {
|
||||
// Convert to Package
|
||||
var pkg cozyv1alpha1.Package
|
||||
if err := runtime.DefaultUnstructuredConverter.FromUnstructured(obj.Object, &pkg); err != nil {
|
||||
return fmt.Errorf("failed to convert Package: %w", err)
|
||||
}
|
||||
|
||||
// Create Package
|
||||
if err := k8sClient.Create(ctx, &pkg); err != nil {
|
||||
return fmt.Errorf("failed to create Package: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Errorf("Package %s not found in file", packageName)
|
||||
}
|
||||
|
||||
func installPackage(ctx context.Context, k8sClient client.Client, packageSourceName string) error {
|
||||
// Get PackageSource
|
||||
packageSource := &cozyv1alpha1.PackageSource{}
|
||||
if err := k8sClient.Get(ctx, client.ObjectKey{Name: packageSourceName}, packageSource); err != nil {
|
||||
return fmt.Errorf("failed to get PackageSource %s: %w", packageSourceName, err)
|
||||
}
|
||||
|
||||
// Build dependency tree
|
||||
dependencyTree, dependencyRequesters, err := buildDependencyTree(ctx, k8sClient, packageSourceName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to build dependency tree: %w", err)
|
||||
}
|
||||
|
||||
// Topological sort (install from root to leaves)
|
||||
installOrder, err := topologicalSort(dependencyTree)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to sort dependencies: %w", err)
|
||||
}
|
||||
|
||||
// Get all PackageSources for variant selection
|
||||
var allPackageSources cozyv1alpha1.PackageSourceList
|
||||
if err := k8sClient.List(ctx, &allPackageSources); err != nil {
|
||||
return fmt.Errorf("failed to list PackageSources: %w", err)
|
||||
}
|
||||
|
||||
packageSourceMap := make(map[string]*cozyv1alpha1.PackageSource)
|
||||
for i := range allPackageSources.Items {
|
||||
packageSourceMap[allPackageSources.Items[i].Name] = &allPackageSources.Items[i]
|
||||
}
|
||||
|
||||
// Get all installed Packages
|
||||
var installedPackages cozyv1alpha1.PackageList
|
||||
if err := k8sClient.List(ctx, &installedPackages); err != nil {
|
||||
return fmt.Errorf("failed to list Packages: %w", err)
|
||||
}
|
||||
|
||||
installedMap := make(map[string]*cozyv1alpha1.Package)
|
||||
for i := range installedPackages.Items {
|
||||
installedMap[installedPackages.Items[i].Name] = &installedPackages.Items[i]
|
||||
}
|
||||
|
||||
// First, collect all variant selections
|
||||
fmt.Fprintf(os.Stderr, "Installing %s and its dependencies...\n\n", packageSourceName)
|
||||
packageVariants := make(map[string]string) // packageName -> variant
|
||||
|
||||
for _, pkgName := range installOrder {
|
||||
// Check if already installed
|
||||
if installed, exists := installedMap[pkgName]; exists {
|
||||
variant := installed.Spec.Variant
|
||||
if variant == "" {
|
||||
variant = "default"
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "✓ %s (already installed, variant: %s)\n", pkgName, variant)
|
||||
packageVariants[pkgName] = variant
|
||||
continue
|
||||
}
|
||||
|
||||
// Get PackageSource for this dependency
|
||||
ps, exists := packageSourceMap[pkgName]
|
||||
if !exists {
|
||||
requester := dependencyRequesters[pkgName]
|
||||
if requester != "" {
|
||||
return fmt.Errorf("PackageSource %s not found (required by %s)", pkgName, requester)
|
||||
}
|
||||
return fmt.Errorf("PackageSource %s not found", pkgName)
|
||||
}
|
||||
|
||||
// Select variant interactively
|
||||
variant, err := selectVariantInteractive(ps)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to select variant for %s: %w", pkgName, err)
|
||||
}
|
||||
|
||||
packageVariants[pkgName] = variant
|
||||
}
|
||||
|
||||
// Now create all Package resources
|
||||
for _, pkgName := range installOrder {
|
||||
// Skip if already installed
|
||||
if _, exists := installedMap[pkgName]; exists {
|
||||
continue
|
||||
}
|
||||
|
||||
variant := packageVariants[pkgName]
|
||||
|
||||
// Create Package
|
||||
pkg := &cozyv1alpha1.Package{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: pkgName,
|
||||
},
|
||||
Spec: cozyv1alpha1.PackageSpec{
|
||||
Variant: variant,
|
||||
},
|
||||
}
|
||||
|
||||
if err := k8sClient.Create(ctx, pkg); err != nil {
|
||||
return fmt.Errorf("failed to create Package %s: %w", pkgName, err)
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "✓ Added Package %s\n", pkgName)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// selectVariantInteractive prompts user to select a variant
|
||||
func selectVariantInteractive(ps *cozyv1alpha1.PackageSource) (string, error) {
|
||||
if len(ps.Spec.Variants) == 0 {
|
||||
return "", fmt.Errorf("no variants available for PackageSource %s", ps.Name)
|
||||
}
|
||||
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
|
||||
fmt.Fprintf(os.Stderr, "\nPackageSource: %s\n", ps.Name)
|
||||
fmt.Fprintf(os.Stderr, "Available variants:\n")
|
||||
for i, variant := range ps.Spec.Variants {
|
||||
fmt.Fprintf(os.Stderr, " %d. %s\n", i+1, variant.Name)
|
||||
}
|
||||
|
||||
// If only one variant, use it as default
|
||||
defaultVariant := ps.Spec.Variants[0].Name
|
||||
var prompt string
|
||||
if len(ps.Spec.Variants) == 1 {
|
||||
prompt = "Select variant [1]: "
|
||||
} else {
|
||||
prompt = fmt.Sprintf("Select variant (1-%d): ", len(ps.Spec.Variants))
|
||||
}
|
||||
|
||||
for {
|
||||
fmt.Fprintf(os.Stderr, prompt)
|
||||
input, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read input: %w", err)
|
||||
}
|
||||
|
||||
input = strings.TrimSpace(input)
|
||||
|
||||
// If input is empty and there's a default variant, use it
|
||||
if input == "" && len(ps.Spec.Variants) == 1 {
|
||||
return defaultVariant, nil
|
||||
}
|
||||
|
||||
choice, err := strconv.Atoi(input)
|
||||
if err != nil || choice < 1 || choice > len(ps.Spec.Variants) {
|
||||
fmt.Fprintf(os.Stderr, "Invalid choice. Please enter a number between 1 and %d.\n", len(ps.Spec.Variants))
|
||||
continue
|
||||
}
|
||||
|
||||
return ps.Spec.Variants[choice-1].Name, nil
|
||||
}
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(addCmd)
|
||||
addCmd.Flags().StringArrayVarP(&addCmdFlags.files, "file", "f", []string{}, "Read packages from file or directory (can be specified multiple times)")
|
||||
addCmd.Flags().StringVar(&addCmdFlags.kubeconfig, "kubeconfig", "", "Path to kubeconfig file (defaults to ~/.kube/config or KUBECONFIG env var)")
|
||||
}
|
||||
|
||||
396
cmd/cozypkg/cmd/del.go
Normal file
396
cmd/cozypkg/cmd/del.go
Normal file
@@ -0,0 +1,396 @@
|
||||
/*
|
||||
Copyright 2025 The Cozystack 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 cmd
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
cozyv1alpha1 "github.com/cozystack/cozystack/api/v1alpha1"
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
utilruntime "k8s.io/apimachinery/pkg/util/runtime"
|
||||
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
|
||||
"k8s.io/client-go/rest"
|
||||
"k8s.io/client-go/tools/clientcmd"
|
||||
ctrl "sigs.k8s.io/controller-runtime"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
_ "k8s.io/client-go/plugin/pkg/client/auth"
|
||||
)
|
||||
|
||||
var delCmdFlags struct {
|
||||
files []string
|
||||
kubeconfig string
|
||||
}
|
||||
|
||||
var delCmd = &cobra.Command{
|
||||
Use: "del [package]...",
|
||||
Short: "Delete Package resources",
|
||||
Long: `Delete Package resources.
|
||||
|
||||
You can specify packages as arguments or use -f flag to read from files.
|
||||
Multiple -f flags can be specified, and they can point to files or directories.`,
|
||||
Args: cobra.ArbitraryArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
|
||||
// Collect package names from arguments and files
|
||||
packageNames := make(map[string]bool)
|
||||
packagesFromFiles := make(map[string]string) // packageName -> filePath
|
||||
|
||||
for _, arg := range args {
|
||||
packageNames[arg] = true
|
||||
}
|
||||
|
||||
// Read packages from files (reuse function from add.go)
|
||||
for _, filePath := range delCmdFlags.files {
|
||||
packages, err := readPackagesFromFile(filePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read packages from %s: %w", filePath, err)
|
||||
}
|
||||
for _, pkg := range packages {
|
||||
packageNames[pkg] = true
|
||||
if oldPath, ok := packagesFromFiles[pkg]; ok {
|
||||
fmt.Fprintf(os.Stderr, "warning: package %q is defined in both %s and %s, using the latter\n", pkg, oldPath, filePath)
|
||||
}
|
||||
packagesFromFiles[pkg] = filePath
|
||||
}
|
||||
}
|
||||
|
||||
if len(packageNames) == 0 {
|
||||
return fmt.Errorf("no packages specified")
|
||||
}
|
||||
|
||||
// Create Kubernetes client config
|
||||
var config *rest.Config
|
||||
var err error
|
||||
|
||||
if delCmdFlags.kubeconfig != "" {
|
||||
config, err = clientcmd.BuildConfigFromFlags("", delCmdFlags.kubeconfig)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load kubeconfig from %s: %w", delCmdFlags.kubeconfig, err)
|
||||
}
|
||||
} else {
|
||||
config, err = ctrl.GetConfig()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get kubeconfig: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
scheme := runtime.NewScheme()
|
||||
utilruntime.Must(clientgoscheme.AddToScheme(scheme))
|
||||
utilruntime.Must(cozyv1alpha1.AddToScheme(scheme))
|
||||
|
||||
k8sClient, err := client.New(config, client.Options{Scheme: scheme})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create k8s client: %w", err)
|
||||
}
|
||||
|
||||
// Check which requested packages are installed
|
||||
var installedPackages cozyv1alpha1.PackageList
|
||||
if err := k8sClient.List(ctx, &installedPackages); err != nil {
|
||||
return fmt.Errorf("failed to list Packages: %w", err)
|
||||
}
|
||||
installedMap := make(map[string]bool)
|
||||
for _, pkg := range installedPackages.Items {
|
||||
installedMap[pkg.Name] = true
|
||||
}
|
||||
|
||||
// Warn about requested packages that are not installed
|
||||
for pkgName := range packageNames {
|
||||
if !installedMap[pkgName] {
|
||||
fmt.Fprintf(os.Stderr, "⚠ Package %s is not installed, skipping\n", pkgName)
|
||||
}
|
||||
}
|
||||
|
||||
// Find all packages to delete (including dependents)
|
||||
packagesToDelete, err := findPackagesToDelete(ctx, k8sClient, packageNames)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to analyze dependencies: %w", err)
|
||||
}
|
||||
|
||||
if len(packagesToDelete) == 0 {
|
||||
fmt.Fprintf(os.Stderr, "No packages found to delete\n")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Show packages to be deleted and ask for confirmation
|
||||
if err := confirmDeletion(packagesToDelete, packageNames); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Delete packages in reverse topological order (dependents first, then dependencies)
|
||||
deleteOrder, err := getDeleteOrder(ctx, k8sClient, packagesToDelete)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to determine delete order: %w", err)
|
||||
}
|
||||
|
||||
// Delete each package
|
||||
for _, packageName := range deleteOrder {
|
||||
pkg := &cozyv1alpha1.Package{}
|
||||
pkg.Name = packageName
|
||||
if err := k8sClient.Delete(ctx, pkg); err != nil {
|
||||
if apierrors.IsNotFound(err) {
|
||||
fmt.Fprintf(os.Stderr, "⚠ Package %s not found, skipping\n", packageName)
|
||||
continue
|
||||
}
|
||||
return fmt.Errorf("failed to delete Package %s: %w", packageName, err)
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "✓ Deleted Package %s\n", packageName)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// findPackagesToDelete finds all packages that need to be deleted, including dependents
|
||||
func findPackagesToDelete(ctx context.Context, k8sClient client.Client, requestedPackages map[string]bool) (map[string]bool, error) {
|
||||
// Get all installed Packages
|
||||
var installedPackages cozyv1alpha1.PackageList
|
||||
if err := k8sClient.List(ctx, &installedPackages); err != nil {
|
||||
return nil, fmt.Errorf("failed to list Packages: %w", err)
|
||||
}
|
||||
|
||||
installedMap := make(map[string]bool)
|
||||
for _, pkg := range installedPackages.Items {
|
||||
installedMap[pkg.Name] = true
|
||||
}
|
||||
|
||||
// Get all PackageSources to build dependency graph
|
||||
var packageSources cozyv1alpha1.PackageSourceList
|
||||
if err := k8sClient.List(ctx, &packageSources); err != nil {
|
||||
return nil, fmt.Errorf("failed to list PackageSources: %w", err)
|
||||
}
|
||||
|
||||
// Build reverse dependency graph (dependents -> dependencies)
|
||||
// This tells us: for each package, which packages depend on it
|
||||
reverseDeps := make(map[string][]string)
|
||||
for _, ps := range packageSources.Items {
|
||||
// Only consider installed packages
|
||||
if !installedMap[ps.Name] {
|
||||
continue
|
||||
}
|
||||
for _, variant := range ps.Spec.Variants {
|
||||
for _, dep := range variant.DependsOn {
|
||||
// Only consider installed dependencies
|
||||
if installedMap[dep] {
|
||||
reverseDeps[dep] = append(reverseDeps[dep], ps.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Find all packages to delete (requested + their dependents)
|
||||
packagesToDelete := make(map[string]bool)
|
||||
visited := make(map[string]bool)
|
||||
|
||||
var findDependents func(string)
|
||||
findDependents = func(pkgName string) {
|
||||
if visited[pkgName] {
|
||||
return
|
||||
}
|
||||
visited[pkgName] = true
|
||||
|
||||
// Only add if it's installed
|
||||
if installedMap[pkgName] {
|
||||
packagesToDelete[pkgName] = true
|
||||
}
|
||||
|
||||
// Recursively find all dependents
|
||||
for _, dependent := range reverseDeps[pkgName] {
|
||||
if installedMap[dependent] {
|
||||
findDependents(dependent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Start from requested packages
|
||||
for pkgName := range requestedPackages {
|
||||
if !installedMap[pkgName] {
|
||||
continue
|
||||
}
|
||||
findDependents(pkgName)
|
||||
}
|
||||
|
||||
return packagesToDelete, nil
|
||||
}
|
||||
|
||||
// confirmDeletion shows the list of packages to be deleted and asks for user confirmation
|
||||
func confirmDeletion(packagesToDelete map[string]bool, requestedPackages map[string]bool) error {
|
||||
// Separate requested packages from dependents
|
||||
var requested []string
|
||||
var dependents []string
|
||||
|
||||
for pkg := range packagesToDelete {
|
||||
if requestedPackages[pkg] {
|
||||
requested = append(requested, pkg)
|
||||
} else {
|
||||
dependents = append(dependents, pkg)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "\nThe following packages will be deleted:\n\n")
|
||||
|
||||
if len(requested) > 0 {
|
||||
fmt.Fprintf(os.Stderr, "Requested packages:\n")
|
||||
for _, pkg := range requested {
|
||||
fmt.Fprintf(os.Stderr, " - %s\n", pkg)
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "\n")
|
||||
}
|
||||
|
||||
if len(dependents) > 0 {
|
||||
fmt.Fprintf(os.Stderr, "Dependent packages (will also be deleted):\n")
|
||||
for _, pkg := range dependents {
|
||||
fmt.Fprintf(os.Stderr, " - %s\n", pkg)
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "\n")
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "Total: %d package(s)\n\n", len(packagesToDelete))
|
||||
fmt.Fprintf(os.Stderr, "Do you want to continue? [y/N]: ")
|
||||
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
input, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read input: %w", err)
|
||||
}
|
||||
|
||||
input = strings.TrimSpace(strings.ToLower(input))
|
||||
if input != "y" && input != "yes" {
|
||||
return fmt.Errorf("deletion cancelled")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getDeleteOrder returns packages in reverse topological order (dependents first, then dependencies)
|
||||
// This ensures we delete dependents before their dependencies
|
||||
func getDeleteOrder(ctx context.Context, k8sClient client.Client, packagesToDelete map[string]bool) ([]string, error) {
|
||||
// Get all PackageSources to build dependency graph
|
||||
var packageSources cozyv1alpha1.PackageSourceList
|
||||
if err := k8sClient.List(ctx, &packageSources); err != nil {
|
||||
return nil, fmt.Errorf("failed to list PackageSources: %w", err)
|
||||
}
|
||||
|
||||
// Build forward dependency graph (package -> dependencies)
|
||||
dependencyGraph := make(map[string][]string)
|
||||
for _, ps := range packageSources.Items {
|
||||
if !packagesToDelete[ps.Name] {
|
||||
continue
|
||||
}
|
||||
deps := make(map[string]bool)
|
||||
for _, variant := range ps.Spec.Variants {
|
||||
for _, dep := range variant.DependsOn {
|
||||
if packagesToDelete[dep] {
|
||||
deps[dep] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
var depList []string
|
||||
for dep := range deps {
|
||||
depList = append(depList, dep)
|
||||
}
|
||||
dependencyGraph[ps.Name] = depList
|
||||
}
|
||||
|
||||
// Build reverse graph for topological sort
|
||||
reverseGraph := make(map[string][]string)
|
||||
allNodes := make(map[string]bool)
|
||||
|
||||
for node, deps := range dependencyGraph {
|
||||
allNodes[node] = true
|
||||
for _, dep := range deps {
|
||||
allNodes[dep] = true
|
||||
reverseGraph[dep] = append(reverseGraph[dep], node)
|
||||
}
|
||||
}
|
||||
|
||||
// Add nodes that have no dependencies
|
||||
for pkg := range packagesToDelete {
|
||||
if !allNodes[pkg] {
|
||||
allNodes[pkg] = true
|
||||
dependencyGraph[pkg] = []string{}
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate in-degrees
|
||||
inDegree := make(map[string]int)
|
||||
for node := range allNodes {
|
||||
inDegree[node] = 0
|
||||
}
|
||||
for node, deps := range dependencyGraph {
|
||||
inDegree[node] = len(deps)
|
||||
}
|
||||
|
||||
// Kahn's algorithm - start with nodes that have no dependencies
|
||||
var queue []string
|
||||
for node, degree := range inDegree {
|
||||
if degree == 0 {
|
||||
queue = append(queue, node)
|
||||
}
|
||||
}
|
||||
|
||||
var result []string
|
||||
for len(queue) > 0 {
|
||||
node := queue[0]
|
||||
queue = queue[1:]
|
||||
result = append(result, node)
|
||||
|
||||
// Process dependents
|
||||
for _, dependent := range reverseGraph[node] {
|
||||
inDegree[dependent]--
|
||||
if inDegree[dependent] == 0 {
|
||||
queue = append(queue, dependent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for cycles: if not all nodes were processed, there's a cycle
|
||||
if len(result) != len(allNodes) {
|
||||
// Find unprocessed nodes
|
||||
processed := make(map[string]bool)
|
||||
for _, node := range result {
|
||||
processed[node] = true
|
||||
}
|
||||
var unprocessed []string
|
||||
for node := range allNodes {
|
||||
if !processed[node] {
|
||||
unprocessed = append(unprocessed, node)
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("dependency cycle detected: the following packages form a cycle and cannot be deleted: %v", unprocessed)
|
||||
}
|
||||
|
||||
// Reverse the result to get dependents first, then dependencies
|
||||
for i, j := 0, len(result)-1; i < j; i, j = i+1, j-1 {
|
||||
result[i], result[j] = result[j], result[i]
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(delCmd)
|
||||
delCmd.Flags().StringArrayVarP(&delCmdFlags.files, "file", "f", []string{}, "Read packages from file or directory (can be specified multiple times)")
|
||||
delCmd.Flags().StringVar(&delCmdFlags.kubeconfig, "kubeconfig", "", "Path to kubeconfig file (defaults to ~/.kube/config or KUBECONFIG env var)")
|
||||
}
|
||||
|
||||
564
cmd/cozypkg/cmd/dependencies.go
Normal file
564
cmd/cozypkg/cmd/dependencies.go
Normal file
@@ -0,0 +1,564 @@
|
||||
/*
|
||||
Copyright 2025 The Cozystack 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 cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
cozyv1alpha1 "github.com/cozystack/cozystack/api/v1alpha1"
|
||||
"github.com/emicklei/dot"
|
||||
"github.com/spf13/cobra"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
utilruntime "k8s.io/apimachinery/pkg/util/runtime"
|
||||
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
|
||||
_ "k8s.io/client-go/plugin/pkg/client/auth"
|
||||
"k8s.io/client-go/rest"
|
||||
"k8s.io/client-go/tools/clientcmd"
|
||||
ctrl "sigs.k8s.io/controller-runtime"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
)
|
||||
|
||||
var dotCmdFlags struct {
|
||||
installed bool
|
||||
components bool
|
||||
files []string
|
||||
kubeconfig string
|
||||
}
|
||||
|
||||
var dotCmd = &cobra.Command{
|
||||
Use: "dot [package]...",
|
||||
Short: "Generate dependency graph as graphviz DOT format",
|
||||
Long: `Generate dependency graph as graphviz DOT format.
|
||||
|
||||
Pipe the output through the "dot" program (part of graphviz package) to render the graph:
|
||||
|
||||
cozypkg dot | dot -Tpng > graph.png
|
||||
|
||||
By default, shows dependencies for all PackageSource resources.
|
||||
Use --installed to show only installed Package resources.
|
||||
Specify packages as arguments or use -f flag to read from files.`,
|
||||
Args: cobra.ArbitraryArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
|
||||
// Collect package names from arguments and files
|
||||
packageNames := make(map[string]bool)
|
||||
for _, arg := range args {
|
||||
packageNames[arg] = true
|
||||
}
|
||||
|
||||
// Read packages from files (reuse function from add.go)
|
||||
for _, filePath := range dotCmdFlags.files {
|
||||
packages, err := readPackagesFromFile(filePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read packages from %s: %w", filePath, err)
|
||||
}
|
||||
for _, pkg := range packages {
|
||||
packageNames[pkg] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to slice, empty means all packages
|
||||
var selectedPackages []string
|
||||
if len(packageNames) > 0 {
|
||||
for pkg := range packageNames {
|
||||
selectedPackages = append(selectedPackages, pkg)
|
||||
}
|
||||
}
|
||||
|
||||
// If multiple packages specified, show graph for all of them
|
||||
// If single package, use packageName for backward compatibility
|
||||
var packageName string
|
||||
if len(selectedPackages) == 1 {
|
||||
packageName = selectedPackages[0]
|
||||
} else if len(selectedPackages) > 1 {
|
||||
// Multiple packages - pass empty string to packageName, use selectedPackages
|
||||
packageName = ""
|
||||
}
|
||||
|
||||
// packagesOnly is inverse of components flag (if components=false, then packagesOnly=true)
|
||||
packagesOnly := !dotCmdFlags.components
|
||||
graph, allNodes, edgeVariants, packageNames, err := buildGraphFromCluster(ctx, dotCmdFlags.kubeconfig, packagesOnly, dotCmdFlags.installed, packageName, selectedPackages)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error getting PackageSource dependencies: %w", err)
|
||||
}
|
||||
|
||||
dotGraph := generateDOTGraph(graph, allNodes, packagesOnly, edgeVariants, packageNames)
|
||||
dotGraph.Write(os.Stdout)
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(dotCmd)
|
||||
dotCmd.Flags().BoolVarP(&dotCmdFlags.installed, "installed", "i", false, "show dependencies only for installed Package resources")
|
||||
dotCmd.Flags().BoolVar(&dotCmdFlags.components, "components", false, "show component-level dependencies")
|
||||
dotCmd.Flags().StringArrayVarP(&dotCmdFlags.files, "file", "f", []string{}, "Read packages from file or directory (can be specified multiple times)")
|
||||
dotCmd.Flags().StringVar(&dotCmdFlags.kubeconfig, "kubeconfig", "", "Path to kubeconfig file (defaults to ~/.kube/config or KUBECONFIG env var)")
|
||||
}
|
||||
|
||||
var (
|
||||
dependenciesScheme = runtime.NewScheme()
|
||||
)
|
||||
|
||||
func init() {
|
||||
utilruntime.Must(clientgoscheme.AddToScheme(dependenciesScheme))
|
||||
utilruntime.Must(cozyv1alpha1.AddToScheme(dependenciesScheme))
|
||||
}
|
||||
|
||||
// buildGraphFromCluster builds a dependency graph from PackageSource resources in the cluster.
|
||||
// Returns: graph, allNodes, edgeVariants (map[edgeKey]variants), packageNames, error
|
||||
func buildGraphFromCluster(ctx context.Context, kubeconfig string, packagesOnly bool, installedOnly bool, packageName string, selectedPackages []string) (map[string][]string, map[string]bool, map[string][]string, map[string]bool, error) {
|
||||
// Create Kubernetes client config
|
||||
var config *rest.Config
|
||||
var err error
|
||||
|
||||
if kubeconfig != "" {
|
||||
// Load kubeconfig from explicit path
|
||||
config, err = clientcmd.BuildConfigFromFlags("", kubeconfig)
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, fmt.Errorf("failed to load kubeconfig from %s: %w", kubeconfig, err)
|
||||
}
|
||||
} else {
|
||||
// Use default kubeconfig loading (from env var or ~/.kube/config)
|
||||
config, err = ctrl.GetConfig()
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, fmt.Errorf("failed to get kubeconfig: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
k8sClient, err := client.New(config, client.Options{Scheme: dependenciesScheme})
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, fmt.Errorf("failed to create k8s client: %w", err)
|
||||
}
|
||||
|
||||
// Get installed Packages if needed
|
||||
installedPackages := make(map[string]bool)
|
||||
if installedOnly {
|
||||
var packageList cozyv1alpha1.PackageList
|
||||
if err := k8sClient.List(ctx, &packageList); err != nil {
|
||||
return nil, nil, nil, nil, fmt.Errorf("failed to list Packages: %w", err)
|
||||
}
|
||||
for _, pkg := range packageList.Items {
|
||||
installedPackages[pkg.Name] = true
|
||||
}
|
||||
}
|
||||
|
||||
// List all PackageSource resources
|
||||
var packageSourceList cozyv1alpha1.PackageSourceList
|
||||
if err := k8sClient.List(ctx, &packageSourceList); err != nil {
|
||||
return nil, nil, nil, nil, fmt.Errorf("failed to list PackageSources: %w", err)
|
||||
}
|
||||
|
||||
// Build map of existing packages and components
|
||||
packageNames := make(map[string]bool)
|
||||
allExistingComponents := make(map[string]bool) // "package.component" -> true
|
||||
for _, ps := range packageSourceList.Items {
|
||||
if ps.Name != "" {
|
||||
packageNames[ps.Name] = true
|
||||
for _, variant := range ps.Spec.Variants {
|
||||
for _, component := range variant.Components {
|
||||
if component.Install != nil {
|
||||
componentFullName := fmt.Sprintf("%s.%s", ps.Name, component.Name)
|
||||
allExistingComponents[componentFullName] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
graph := make(map[string][]string)
|
||||
allNodes := make(map[string]bool)
|
||||
edgeVariants := make(map[string][]string) // key: "source->target", value: list of variant names
|
||||
existingEdges := make(map[string]bool) // key: "source->target" to avoid duplicates
|
||||
componentHasLocalDeps := make(map[string]bool) // componentName -> has local component dependencies
|
||||
|
||||
// Process each PackageSource
|
||||
for _, ps := range packageSourceList.Items {
|
||||
psName := ps.Name
|
||||
if psName == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Filter by package name if specified
|
||||
if packageName != "" && psName != packageName {
|
||||
continue
|
||||
}
|
||||
|
||||
// Filter by selected packages if specified
|
||||
if len(selectedPackages) > 0 {
|
||||
found := false
|
||||
for _, selected := range selectedPackages {
|
||||
if psName == selected {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Filter by installed packages if flag is set
|
||||
if installedOnly && !installedPackages[psName] {
|
||||
continue
|
||||
}
|
||||
|
||||
allNodes[psName] = true
|
||||
|
||||
// Track package dependencies per variant
|
||||
packageDepVariants := make(map[string]map[string]bool) // dep -> variant -> true
|
||||
allVariantNames := make(map[string]bool)
|
||||
for _, v := range ps.Spec.Variants {
|
||||
allVariantNames[v.Name] = true
|
||||
}
|
||||
|
||||
// Track component dependencies per variant
|
||||
componentDepVariants := make(map[string]map[string]map[string]bool) // componentName -> dep -> variant -> true
|
||||
componentVariants := make(map[string]map[string]bool) // componentName -> variant -> true
|
||||
|
||||
// Extract dependencies from variants
|
||||
for _, variant := range ps.Spec.Variants {
|
||||
// Variant-level dependencies (package-level)
|
||||
for _, dep := range variant.DependsOn {
|
||||
// If installedOnly is set, only include dependencies that are installed
|
||||
if installedOnly && !installedPackages[dep] {
|
||||
continue
|
||||
}
|
||||
|
||||
// Track which variant this dependency comes from
|
||||
if packageDepVariants[dep] == nil {
|
||||
packageDepVariants[dep] = make(map[string]bool)
|
||||
}
|
||||
packageDepVariants[dep][variant.Name] = true
|
||||
|
||||
edgeKey := fmt.Sprintf("%s->%s", psName, dep)
|
||||
if !existingEdges[edgeKey] {
|
||||
graph[psName] = append(graph[psName], dep)
|
||||
existingEdges[edgeKey] = true
|
||||
}
|
||||
|
||||
// Add to allNodes only if package exists
|
||||
if packageNames[dep] {
|
||||
allNodes[dep] = true
|
||||
}
|
||||
// If package doesn't exist, don't add to allNodes - it will be shown as missing (red)
|
||||
}
|
||||
|
||||
// Component-level dependencies
|
||||
if !packagesOnly {
|
||||
for _, component := range variant.Components {
|
||||
// Skip components without install section
|
||||
if component.Install == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
componentName := fmt.Sprintf("%s.%s", psName, component.Name)
|
||||
allNodes[componentName] = true
|
||||
|
||||
// Track which variants this component appears in
|
||||
if componentVariants[componentName] == nil {
|
||||
componentVariants[componentName] = make(map[string]bool)
|
||||
}
|
||||
componentVariants[componentName][variant.Name] = true
|
||||
|
||||
if component.Install != nil {
|
||||
if componentDepVariants[componentName] == nil {
|
||||
componentDepVariants[componentName] = make(map[string]map[string]bool)
|
||||
}
|
||||
|
||||
for _, dep := range component.Install.DependsOn {
|
||||
// Track which variant this dependency comes from
|
||||
if componentDepVariants[componentName][dep] == nil {
|
||||
componentDepVariants[componentName][dep] = make(map[string]bool)
|
||||
}
|
||||
componentDepVariants[componentName][dep][variant.Name] = true
|
||||
|
||||
// Check if it's a local component dependency or external
|
||||
if strings.Contains(dep, ".") {
|
||||
// External component dependency (package.component format)
|
||||
// Mark that this component has local dependencies (for edge to package logic)
|
||||
componentHasLocalDeps[componentName] = true
|
||||
|
||||
// Check if target component exists
|
||||
if allExistingComponents[dep] {
|
||||
// Component exists
|
||||
edgeKey := fmt.Sprintf("%s->%s", componentName, dep)
|
||||
if !existingEdges[edgeKey] {
|
||||
graph[componentName] = append(graph[componentName], dep)
|
||||
existingEdges[edgeKey] = true
|
||||
}
|
||||
allNodes[dep] = true
|
||||
} else {
|
||||
// Component doesn't exist - create missing component node
|
||||
edgeKey := fmt.Sprintf("%s->%s", componentName, dep)
|
||||
if !existingEdges[edgeKey] {
|
||||
graph[componentName] = append(graph[componentName], dep)
|
||||
existingEdges[edgeKey] = true
|
||||
}
|
||||
// Don't add to allNodes - will be shown as missing (red)
|
||||
|
||||
// Add edge from missing component to its package
|
||||
parts := strings.SplitN(dep, ".", 2)
|
||||
if len(parts) == 2 {
|
||||
depPackageName := parts[0]
|
||||
missingEdgeKey := fmt.Sprintf("%s->%s", dep, depPackageName)
|
||||
if !existingEdges[missingEdgeKey] {
|
||||
graph[dep] = append(graph[dep], depPackageName)
|
||||
existingEdges[missingEdgeKey] = true
|
||||
}
|
||||
// Add package to allNodes only if it exists
|
||||
if packageNames[depPackageName] {
|
||||
allNodes[depPackageName] = true
|
||||
}
|
||||
// If package doesn't exist, it will be shown as missing (red)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Local component dependency (same package)
|
||||
// Mark that this component has local dependencies
|
||||
componentHasLocalDeps[componentName] = true
|
||||
|
||||
localDep := fmt.Sprintf("%s.%s", psName, dep)
|
||||
|
||||
// Check if target component exists
|
||||
if allExistingComponents[localDep] {
|
||||
// Component exists
|
||||
edgeKey := fmt.Sprintf("%s->%s", componentName, localDep)
|
||||
if !existingEdges[edgeKey] {
|
||||
graph[componentName] = append(graph[componentName], localDep)
|
||||
existingEdges[edgeKey] = true
|
||||
}
|
||||
allNodes[localDep] = true
|
||||
} else {
|
||||
// Component doesn't exist - create missing component node
|
||||
edgeKey := fmt.Sprintf("%s->%s", componentName, localDep)
|
||||
if !existingEdges[edgeKey] {
|
||||
graph[componentName] = append(graph[componentName], localDep)
|
||||
existingEdges[edgeKey] = true
|
||||
}
|
||||
// Don't add to allNodes - will be shown as missing (red)
|
||||
|
||||
// Add edge from missing component to its package
|
||||
missingEdgeKey := fmt.Sprintf("%s->%s", localDep, psName)
|
||||
if !existingEdges[missingEdgeKey] {
|
||||
graph[localDep] = append(graph[localDep], psName)
|
||||
existingEdges[missingEdgeKey] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Store variant information for package dependencies that are not in all variants
|
||||
for dep, variants := range packageDepVariants {
|
||||
if len(variants) < len(allVariantNames) {
|
||||
var variantList []string
|
||||
for v := range variants {
|
||||
variantList = append(variantList, v)
|
||||
}
|
||||
edgeKey := fmt.Sprintf("%s->%s", psName, dep)
|
||||
edgeVariants[edgeKey] = variantList
|
||||
}
|
||||
}
|
||||
|
||||
// Add component->package edges for components without local dependencies
|
||||
if !packagesOnly {
|
||||
for componentName := range componentVariants {
|
||||
// Only add edge to package if component has no local component dependencies
|
||||
if !componentHasLocalDeps[componentName] {
|
||||
edgeKey := fmt.Sprintf("%s->%s", componentName, psName)
|
||||
if !existingEdges[edgeKey] {
|
||||
graph[componentName] = append(graph[componentName], psName)
|
||||
existingEdges[edgeKey] = true
|
||||
}
|
||||
|
||||
// If component is not in all variants, store variant info for component->package edge
|
||||
componentAllVariants := componentVariants[componentName]
|
||||
if len(componentAllVariants) < len(allVariantNames) {
|
||||
var variantList []string
|
||||
for v := range componentAllVariants {
|
||||
variantList = append(variantList, v)
|
||||
}
|
||||
edgeVariants[edgeKey] = variantList
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Store variant information for component dependencies that are not in all variants
|
||||
for componentName, deps := range componentDepVariants {
|
||||
componentAllVariants := componentVariants[componentName]
|
||||
for dep, variants := range deps {
|
||||
if len(variants) < len(componentAllVariants) {
|
||||
var variantList []string
|
||||
for v := range variants {
|
||||
variantList = append(variantList, v)
|
||||
}
|
||||
// Determine the actual target name
|
||||
var targetName string
|
||||
if strings.Contains(dep, ".") {
|
||||
targetName = dep
|
||||
} else {
|
||||
targetName = fmt.Sprintf("%s.%s", psName, dep)
|
||||
}
|
||||
edgeKey := fmt.Sprintf("%s->%s", componentName, targetName)
|
||||
edgeVariants[edgeKey] = variantList
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return graph, allNodes, edgeVariants, packageNames, nil
|
||||
}
|
||||
|
||||
// generateDOTGraph generates a DOT graph from the dependency graph.
|
||||
func generateDOTGraph(graph map[string][]string, allNodes map[string]bool, packagesOnly bool, edgeVariants map[string][]string, packageNames map[string]bool) *dot.Graph {
|
||||
g := dot.NewGraph(dot.Directed)
|
||||
g.Attr("rankdir", "RL")
|
||||
g.Attr("nodesep", "0.5")
|
||||
g.Attr("ranksep", "1.0")
|
||||
|
||||
// Helper function to check if a node is a package
|
||||
// A node is a package if:
|
||||
// 1. It's directly in packageNames
|
||||
// 2. It doesn't contain a dot (simple package name)
|
||||
// 3. It contains a dot but the part before the first dot is a package name
|
||||
isPackage := func(nodeName string) bool {
|
||||
if packageNames[nodeName] {
|
||||
return true
|
||||
}
|
||||
if !strings.Contains(nodeName, ".") {
|
||||
return true
|
||||
}
|
||||
// If it contains a dot, check if the part before the first dot is a package
|
||||
parts := strings.SplitN(nodeName, ".", 2)
|
||||
if len(parts) > 0 {
|
||||
return packageNames[parts[0]]
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Add nodes
|
||||
for node := range allNodes {
|
||||
if packagesOnly && !isPackage(node) {
|
||||
// Skip component nodes when packages-only is enabled
|
||||
continue
|
||||
}
|
||||
|
||||
n := g.Node(node)
|
||||
|
||||
// Style nodes based on type
|
||||
if isPackage(node) {
|
||||
// Package node
|
||||
n.Attr("shape", "box")
|
||||
n.Attr("style", "rounded,filled")
|
||||
n.Attr("fillcolor", "lightblue")
|
||||
n.Attr("label", node)
|
||||
} else {
|
||||
// Component node
|
||||
n.Attr("shape", "box")
|
||||
n.Attr("style", "rounded,filled")
|
||||
n.Attr("fillcolor", "lightyellow")
|
||||
// Extract component name (part after last dot)
|
||||
parts := strings.Split(node, ".")
|
||||
if len(parts) > 0 {
|
||||
n.Attr("label", parts[len(parts)-1])
|
||||
} else {
|
||||
n.Attr("label", node)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add edges
|
||||
for source, targets := range graph {
|
||||
if packagesOnly && !isPackage(source) {
|
||||
// Skip component edges when packages-only is enabled
|
||||
continue
|
||||
}
|
||||
|
||||
for _, target := range targets {
|
||||
if packagesOnly && !isPackage(target) {
|
||||
// Skip component edges when packages-only is enabled
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if target exists
|
||||
targetExists := allNodes[target]
|
||||
|
||||
// Determine edge type for coloring
|
||||
sourceIsPackage := isPackage(source)
|
||||
targetIsPackage := isPackage(target)
|
||||
|
||||
// Add edge
|
||||
edge := g.Edge(g.Node(source), g.Node(target))
|
||||
|
||||
// Set edge color based on type (if target exists)
|
||||
if targetExists {
|
||||
if sourceIsPackage && targetIsPackage {
|
||||
// Package -> Package: black (default)
|
||||
edge.Attr("color", "black")
|
||||
} else {
|
||||
// Component -> Package or Component -> Component: green
|
||||
edge.Attr("color", "green")
|
||||
}
|
||||
}
|
||||
|
||||
// If target doesn't exist, mark it as missing (red color)
|
||||
if !targetExists {
|
||||
edge.Attr("color", "red")
|
||||
edge.Attr("style", "dashed")
|
||||
|
||||
// Also add the missing node with red color
|
||||
missingNode := g.Node(target)
|
||||
missingNode.Attr("shape", "box")
|
||||
missingNode.Attr("style", "rounded,filled,dashed")
|
||||
missingNode.Attr("fillcolor", "lightcoral")
|
||||
|
||||
// Determine label based on node type
|
||||
if isPackage(target) {
|
||||
// Package node
|
||||
missingNode.Attr("label", target)
|
||||
} else {
|
||||
// Component node - extract component name
|
||||
parts := strings.Split(target, ".")
|
||||
if len(parts) > 0 {
|
||||
missingNode.Attr("label", parts[len(parts)-1])
|
||||
} else {
|
||||
missingNode.Attr("label", target)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Check if this edge has variant information (dependency not in all variants)
|
||||
edgeKey := fmt.Sprintf("%s->%s", source, target)
|
||||
if variants, hasVariants := edgeVariants[edgeKey]; hasVariants {
|
||||
// Add label with variant names
|
||||
edge.Attr("label", strings.Join(variants, ","))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return g
|
||||
}
|
||||
220
cmd/cozypkg/cmd/list.go
Normal file
220
cmd/cozypkg/cmd/list.go
Normal file
@@ -0,0 +1,220 @@
|
||||
/*
|
||||
Copyright 2025 The Cozystack 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 cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
cozyv1alpha1 "github.com/cozystack/cozystack/api/v1alpha1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
utilruntime "k8s.io/apimachinery/pkg/util/runtime"
|
||||
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
|
||||
"k8s.io/client-go/rest"
|
||||
"k8s.io/client-go/tools/clientcmd"
|
||||
ctrl "sigs.k8s.io/controller-runtime"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
_ "k8s.io/client-go/plugin/pkg/client/auth"
|
||||
)
|
||||
|
||||
var listCmdFlags struct {
|
||||
installed bool
|
||||
components bool
|
||||
kubeconfig string
|
||||
}
|
||||
|
||||
var listCmd = &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List PackageSource or Package resources",
|
||||
Long: `List PackageSource or Package resources in table format.
|
||||
|
||||
By default, lists PackageSource resources. Use --installed flag to list installed Package resources.
|
||||
Use --components flag to show components on separate lines.`,
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
|
||||
// Create Kubernetes client config
|
||||
var config *rest.Config
|
||||
var err error
|
||||
|
||||
if listCmdFlags.kubeconfig != "" {
|
||||
config, err = clientcmd.BuildConfigFromFlags("", listCmdFlags.kubeconfig)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load kubeconfig from %s: %w", listCmdFlags.kubeconfig, err)
|
||||
}
|
||||
} else {
|
||||
config, err = ctrl.GetConfig()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get kubeconfig: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
scheme := runtime.NewScheme()
|
||||
utilruntime.Must(clientgoscheme.AddToScheme(scheme))
|
||||
utilruntime.Must(cozyv1alpha1.AddToScheme(scheme))
|
||||
|
||||
k8sClient, err := client.New(config, client.Options{Scheme: scheme})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create k8s client: %w", err)
|
||||
}
|
||||
|
||||
if listCmdFlags.installed {
|
||||
return listPackages(ctx, k8sClient, listCmdFlags.components)
|
||||
}
|
||||
return listPackageSources(ctx, k8sClient, listCmdFlags.components)
|
||||
},
|
||||
}
|
||||
|
||||
func listPackageSources(ctx context.Context, k8sClient client.Client, showComponents bool) error {
|
||||
var psList cozyv1alpha1.PackageSourceList
|
||||
if err := k8sClient.List(ctx, &psList); err != nil {
|
||||
return fmt.Errorf("failed to list PackageSources: %w", err)
|
||||
}
|
||||
|
||||
// Use tabwriter for better column alignment
|
||||
w := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0)
|
||||
defer w.Flush()
|
||||
|
||||
// Print header
|
||||
fmt.Fprintln(w, "NAME\tVARIANTS\tREADY\tSTATUS")
|
||||
|
||||
// Print rows
|
||||
for _, ps := range psList.Items {
|
||||
// Get variants
|
||||
var variants []string
|
||||
for _, variant := range ps.Spec.Variants {
|
||||
variants = append(variants, variant.Name)
|
||||
}
|
||||
variantsStr := strings.Join(variants, ",")
|
||||
if len(variantsStr) > 28 {
|
||||
variantsStr = variantsStr[:25] + "..."
|
||||
}
|
||||
|
||||
// Get Ready condition
|
||||
ready := "Unknown"
|
||||
status := ""
|
||||
for _, condition := range ps.Status.Conditions {
|
||||
if condition.Type == "Ready" {
|
||||
ready = string(condition.Status)
|
||||
status = condition.Message
|
||||
if len(status) > 48 {
|
||||
status = status[:45] + "..."
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", ps.Name, variantsStr, ready, status)
|
||||
|
||||
// Show components if requested
|
||||
if showComponents {
|
||||
for _, variant := range ps.Spec.Variants {
|
||||
for _, component := range variant.Components {
|
||||
fmt.Fprintf(w, " %s\t%s\t\t\n",
|
||||
fmt.Sprintf("%s.%s", ps.Name, component.Name),
|
||||
variant.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func listPackages(ctx context.Context, k8sClient client.Client, showComponents bool) error {
|
||||
var pkgList cozyv1alpha1.PackageList
|
||||
if err := k8sClient.List(ctx, &pkgList); err != nil {
|
||||
return fmt.Errorf("failed to list Packages: %w", err)
|
||||
}
|
||||
|
||||
// Fetch all PackageSource resources once if components are requested
|
||||
var psMap map[string]*cozyv1alpha1.PackageSource
|
||||
if showComponents {
|
||||
var psList cozyv1alpha1.PackageSourceList
|
||||
if err := k8sClient.List(ctx, &psList); err != nil {
|
||||
return fmt.Errorf("failed to list PackageSources: %w", err)
|
||||
}
|
||||
psMap = make(map[string]*cozyv1alpha1.PackageSource)
|
||||
for i := range psList.Items {
|
||||
psMap[psList.Items[i].Name] = &psList.Items[i]
|
||||
}
|
||||
}
|
||||
|
||||
// Use tabwriter for better column alignment
|
||||
w := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0)
|
||||
defer w.Flush()
|
||||
|
||||
// Print header
|
||||
fmt.Fprintln(w, "NAME\tVARIANT\tREADY\tSTATUS")
|
||||
|
||||
// Print rows
|
||||
for _, pkg := range pkgList.Items {
|
||||
variant := pkg.Spec.Variant
|
||||
if variant == "" {
|
||||
variant = "default"
|
||||
}
|
||||
|
||||
// Get Ready condition
|
||||
ready := "Unknown"
|
||||
status := ""
|
||||
for _, condition := range pkg.Status.Conditions {
|
||||
if condition.Type == "Ready" {
|
||||
ready = string(condition.Status)
|
||||
status = condition.Message
|
||||
if len(status) > 48 {
|
||||
status = status[:45] + "..."
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", pkg.Name, variant, ready, status)
|
||||
|
||||
// Show components if requested
|
||||
if showComponents {
|
||||
// Look up PackageSource from map instead of making API call
|
||||
if ps, exists := psMap[pkg.Name]; exists {
|
||||
// Find the variant
|
||||
for _, v := range ps.Spec.Variants {
|
||||
if v.Name == variant {
|
||||
for _, component := range v.Components {
|
||||
fmt.Fprintf(w, " %s\t%s\t\t\n",
|
||||
fmt.Sprintf("%s.%s", pkg.Name, component.Name),
|
||||
variant)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(listCmd)
|
||||
listCmd.Flags().BoolVarP(&listCmdFlags.installed, "installed", "i", false, "list installed Package resources instead of PackageSource resources")
|
||||
listCmd.Flags().BoolVar(&listCmdFlags.components, "components", false, "show components on separate lines")
|
||||
listCmd.Flags().StringVar(&listCmdFlags.kubeconfig, "kubeconfig", "", "Path to kubeconfig file (defaults to ~/.kube/config or KUBECONFIG env var)")
|
||||
}
|
||||
|
||||
49
cmd/cozypkg/cmd/root.go
Normal file
49
cmd/cozypkg/cmd/root.go
Normal file
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
Copyright 2025 The Cozystack 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 cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// rootCmd represents the base command when called without any subcommands.
|
||||
var rootCmd = &cobra.Command{
|
||||
Use: "cozypkg",
|
||||
Short: "A CLI for managing Cozystack packages",
|
||||
Long: ``,
|
||||
SilenceErrors: true,
|
||||
SilenceUsage: true,
|
||||
DisableAutoGenTag: true,
|
||||
}
|
||||
|
||||
// Execute adds all child commands to the root command and sets flags appropriately.
|
||||
// This is called by main.main(). It only needs to happen once to the rootCmd.
|
||||
func Execute() error {
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err.Error())
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func init() {
|
||||
// Commands are registered in their respective init() functions
|
||||
}
|
||||
|
||||
30
cmd/cozypkg/main.go
Normal file
30
cmd/cozypkg/main.go
Normal file
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
Copyright 2025 The Cozystack 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"
|
||||
|
||||
"github.com/cozystack/cozystack/cmd/cozypkg/cmd"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := cmd.Execute(); err != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"log"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
func main() {
|
||||
addr := flag.String("address", ":8123", "Address to listen on")
|
||||
dir := flag.String("dir", "/cozystack/assets", "Directory to serve files from")
|
||||
flag.Parse()
|
||||
|
||||
absDir, err := filepath.Abs(*dir)
|
||||
if err != nil {
|
||||
log.Fatalf("Error getting absolute path for %s: %v", *dir, err)
|
||||
}
|
||||
|
||||
fs := http.FileServer(http.Dir(absDir))
|
||||
http.Handle("/", fs)
|
||||
|
||||
log.Printf("Server starting on %s, serving directory %s", *addr, absDir)
|
||||
|
||||
err = http.ListenAndServe(*addr, nil)
|
||||
if err != nil {
|
||||
log.Fatalf("Server failed to start: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -200,22 +200,6 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if err = (&controller.TenantHelmReconciler{
|
||||
Client: mgr.GetClient(),
|
||||
Scheme: mgr.GetScheme(),
|
||||
}).SetupWithManager(mgr); err != nil {
|
||||
setupLog.Error(err, "unable to create controller", "controller", "TenantHelmReconciler")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if err = (&controller.CozystackConfigReconciler{
|
||||
Client: mgr.GetClient(),
|
||||
Scheme: mgr.GetScheme(),
|
||||
}).SetupWithManager(mgr); err != nil {
|
||||
setupLog.Error(err, "unable to create controller", "controller", "CozystackConfigReconciler")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
cozyAPIKind := "DaemonSet"
|
||||
if reconcileDeployment {
|
||||
cozyAPIKind = "Deployment"
|
||||
@@ -229,6 +213,14 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if err = (&controller.CozystackResourceDefinitionHelmReconciler{
|
||||
Client: mgr.GetClient(),
|
||||
Scheme: mgr.GetScheme(),
|
||||
}).SetupWithManager(mgr); err != nil {
|
||||
setupLog.Error(err, "unable to create controller", "controller", "CozystackResourceDefinitionHelmReconciler")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
dashboardManager := &dashboard.Manager{
|
||||
Client: mgr.GetClient(),
|
||||
Scheme: mgr.GetScheme(),
|
||||
|
||||
502
cmd/cozystack-operator/main.go
Normal file
502
cmd/cozystack-operator/main.go
Normal file
@@ -0,0 +1,502 @@
|
||||
/*
|
||||
Copyright 2025 The Cozystack 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"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
// Import all Kubernetes client auth plugins (e.g. Azure, GCP, OIDC, etc.)
|
||||
// to ensure that exec-entrypoint and run can make use of them.
|
||||
_ "k8s.io/client-go/plugin/pkg/client/auth"
|
||||
|
||||
cozyv1alpha1 "github.com/cozystack/cozystack/api/v1alpha1"
|
||||
helmv2 "github.com/fluxcd/helm-controller/api/v2"
|
||||
sourcev1 "github.com/fluxcd/source-controller/api/v1"
|
||||
sourcewatcherv1beta1 "github.com/fluxcd/source-watcher/api/v2/v1beta1"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/fields"
|
||||
"k8s.io/apimachinery/pkg/labels"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
utilruntime "k8s.io/apimachinery/pkg/util/runtime"
|
||||
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
|
||||
ctrl "sigs.k8s.io/controller-runtime"
|
||||
"sigs.k8s.io/controller-runtime/pkg/cache"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
"sigs.k8s.io/controller-runtime/pkg/healthz"
|
||||
"sigs.k8s.io/controller-runtime/pkg/log"
|
||||
"sigs.k8s.io/controller-runtime/pkg/log/zap"
|
||||
metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server"
|
||||
"sigs.k8s.io/controller-runtime/pkg/webhook"
|
||||
|
||||
"github.com/cozystack/cozystack/internal/cozyvaluesreplicator"
|
||||
"github.com/cozystack/cozystack/internal/fluxinstall"
|
||||
"github.com/cozystack/cozystack/internal/operator"
|
||||
// +kubebuilder:scaffold:imports
|
||||
)
|
||||
|
||||
var (
|
||||
scheme = runtime.NewScheme()
|
||||
setupLog = ctrl.Log.WithName("setup")
|
||||
)
|
||||
|
||||
func init() {
|
||||
utilruntime.Must(clientgoscheme.AddToScheme(scheme))
|
||||
utilruntime.Must(apiextensionsv1.AddToScheme(scheme))
|
||||
utilruntime.Must(cozyv1alpha1.AddToScheme(scheme))
|
||||
utilruntime.Must(helmv2.AddToScheme(scheme))
|
||||
utilruntime.Must(sourcev1.AddToScheme(scheme))
|
||||
utilruntime.Must(sourcewatcherv1beta1.AddToScheme(scheme))
|
||||
// +kubebuilder:scaffold:scheme
|
||||
}
|
||||
|
||||
func main() {
|
||||
var metricsAddr string
|
||||
var enableLeaderElection bool
|
||||
var probeAddr string
|
||||
var secureMetrics bool
|
||||
var enableHTTP2 bool
|
||||
var installFlux bool
|
||||
var cozystackVersion string
|
||||
var cozyValuesSecretName string
|
||||
var cozyValuesSecretNamespace string
|
||||
var cozyValuesNamespaceSelector string
|
||||
var platformSourceURL string
|
||||
var platformSourceName string
|
||||
var platformSourceRef string
|
||||
|
||||
flag.StringVar(&metricsAddr, "metrics-bind-address", ":8080", "The address the metric endpoint binds to.")
|
||||
flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081", "The address the probe endpoint binds to.")
|
||||
flag.BoolVar(&enableLeaderElection, "leader-elect", false,
|
||||
"Enable leader election for controller manager. "+
|
||||
"Enabling this will ensure there is only one active controller manager.")
|
||||
flag.BoolVar(&secureMetrics, "metrics-secure", false,
|
||||
"If set the metrics endpoint is served securely")
|
||||
flag.BoolVar(&enableHTTP2, "enable-http2", false,
|
||||
"If set, HTTP/2 will be enabled for the metrics and webhook servers")
|
||||
flag.BoolVar(&installFlux, "install-flux", false, "Install Flux components before starting reconcile loop")
|
||||
flag.StringVar(&cozystackVersion, "cozystack-version", "unknown",
|
||||
"Version of Cozystack")
|
||||
flag.StringVar(&platformSourceURL, "platform-source-url", "", "Platform source URL (oci:// or https://). If specified, generates OCIRepository or GitRepository resource.")
|
||||
flag.StringVar(&platformSourceName, "platform-source-name", "cozystack-packages", "Name for the generated platform source resource (default: cozystack-packages)")
|
||||
flag.StringVar(&platformSourceRef, "platform-source-ref", "", "Reference specification as key=value pairs (e.g., 'branch=main' or 'digest=sha256:...,tag=v1.0'). For OCI: digest, semver, semverFilter, tag. For Git: branch, tag, semver, name, commit.")
|
||||
flag.StringVar(&cozyValuesSecretName, "cozy-values-secret-name", "cozystack-values", "The name of the secret containing cluster-wide configuration values.")
|
||||
flag.StringVar(&cozyValuesSecretNamespace, "cozy-values-secret-namespace", "cozy-system", "The namespace of the secret containing cluster-wide configuration values.")
|
||||
flag.StringVar(&cozyValuesNamespaceSelector, "cozy-values-namespace-selector", "cozystack.io/system=true", "The label selector for namespaces where the cluster-wide configuration values must be replicated.")
|
||||
|
||||
opts := zap.Options{
|
||||
Development: true,
|
||||
}
|
||||
opts.BindFlags(flag.CommandLine)
|
||||
flag.Parse()
|
||||
|
||||
ctrl.SetLogger(zap.New(zap.UseFlagOptions(&opts)))
|
||||
|
||||
config := ctrl.GetConfigOrDie()
|
||||
|
||||
// Create a direct client (without cache) for pre-start operations
|
||||
directClient, err := client.New(config, client.Options{Scheme: scheme})
|
||||
if err != nil {
|
||||
setupLog.Error(err, "unable to create direct client")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
targetNSSelector, err := labels.Parse(cozyValuesNamespaceSelector)
|
||||
if err != nil {
|
||||
setupLog.Error(err, "could not parse namespace label selector")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Start the controller manager
|
||||
setupLog.Info("Starting controller manager")
|
||||
mgr, err := ctrl.NewManager(config, ctrl.Options{
|
||||
Scheme: scheme,
|
||||
Cache: cache.Options{
|
||||
ByObject: map[client.Object]cache.ByObject{
|
||||
// Cache only Secrets named <secretName> (in any namespace)
|
||||
&corev1.Secret{}: {
|
||||
Field: fields.OneTermEqualSelector("metadata.name", cozyValuesSecretName),
|
||||
},
|
||||
|
||||
// Cache only Namespaces that match a label selector
|
||||
&corev1.Namespace{}: {
|
||||
Label: targetNSSelector,
|
||||
},
|
||||
},
|
||||
},
|
||||
Metrics: metricsserver.Options{
|
||||
BindAddress: metricsAddr,
|
||||
SecureServing: secureMetrics,
|
||||
},
|
||||
WebhookServer: webhook.NewServer(webhook.Options{
|
||||
Port: 9443,
|
||||
}),
|
||||
HealthProbeBindAddress: probeAddr,
|
||||
LeaderElection: enableLeaderElection,
|
||||
LeaderElectionID: "cozystack-operator.cozystack.io",
|
||||
// LeaderElectionReleaseOnCancel defines if the leader should step down voluntarily
|
||||
// when the Manager ends. This requires the binary to immediately end when the
|
||||
// Manager is stopped, otherwise, setting this significantly speeds up voluntary
|
||||
// leader transitions as the new leader don't have to wait LeaseDuration time first.
|
||||
//
|
||||
// In the default scaffold provided, the program ends immediately after
|
||||
// the manager stops, so would be fine to enable this option. However,
|
||||
// if you are doing or is intended to do any operation such as perform cleanups
|
||||
// after the manager stops then its usage might be unsafe.
|
||||
// LeaderElectionReleaseOnCancel: true,
|
||||
})
|
||||
if err != nil {
|
||||
setupLog.Error(err, "unable to start manager")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Install Flux before starting reconcile loop
|
||||
if installFlux {
|
||||
setupLog.Info("Installing Flux components before starting reconcile loop")
|
||||
installCtx, installCancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||
defer installCancel()
|
||||
|
||||
// Use direct client for pre-start operations (cache is not ready yet)
|
||||
if err := fluxinstall.Install(installCtx, directClient, fluxinstall.WriteEmbeddedManifests); err != nil {
|
||||
setupLog.Error(err, "failed to install Flux")
|
||||
os.Exit(1)
|
||||
}
|
||||
setupLog.Info("Flux installation completed successfully")
|
||||
}
|
||||
|
||||
// Generate and install platform source resource if specified
|
||||
if platformSourceURL != "" {
|
||||
setupLog.Info("Generating platform source resource", "url", platformSourceURL, "name", platformSourceName, "ref", platformSourceRef)
|
||||
installCtx, installCancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer installCancel()
|
||||
|
||||
// Use direct client for pre-start operations (cache is not ready yet)
|
||||
if err := installPlatformSourceResource(installCtx, directClient, platformSourceURL, platformSourceName, platformSourceRef); err != nil {
|
||||
setupLog.Error(err, "failed to install platform source resource")
|
||||
os.Exit(1)
|
||||
} else {
|
||||
setupLog.Info("Platform source resource installation completed successfully")
|
||||
}
|
||||
}
|
||||
|
||||
// Setup PackageSource reconciler
|
||||
if err := (&operator.PackageSourceReconciler{
|
||||
Client: mgr.GetClient(),
|
||||
Scheme: mgr.GetScheme(),
|
||||
}).SetupWithManager(mgr); err != nil {
|
||||
setupLog.Error(err, "unable to create controller", "controller", "PackageSource")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Setup Package reconciler
|
||||
if err := (&operator.PackageReconciler{
|
||||
Client: mgr.GetClient(),
|
||||
Scheme: mgr.GetScheme(),
|
||||
}).SetupWithManager(mgr); err != nil {
|
||||
setupLog.Error(err, "unable to create controller", "controller", "Package")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Setup CozyValuesReplicator reconciler
|
||||
if err := (&cozyvaluesreplicator.SecretReplicatorReconciler{
|
||||
Client: mgr.GetClient(),
|
||||
Scheme: mgr.GetScheme(),
|
||||
SourceNamespace: cozyValuesSecretNamespace,
|
||||
SecretName: cozyValuesSecretName,
|
||||
TargetNamespaceSelector: targetNSSelector,
|
||||
}).SetupWithManager(mgr); err != nil {
|
||||
setupLog.Error(err, "unable to create controller", "controller", "CozyValuesReplicator")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// +kubebuilder:scaffold:builder
|
||||
|
||||
if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil {
|
||||
setupLog.Error(err, "unable to set up health check")
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := mgr.AddReadyzCheck("readyz", healthz.Ping); err != nil {
|
||||
setupLog.Error(err, "unable to set up ready check")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
setupLog.Info("Starting controller manager")
|
||||
mgrCtx := ctrl.SetupSignalHandler()
|
||||
if err := mgr.Start(mgrCtx); err != nil {
|
||||
setupLog.Error(err, "problem running manager")
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// installPlatformSourceResource generates and installs a Flux source resource (OCIRepository or GitRepository)
|
||||
// based on the platform source URL
|
||||
func installPlatformSourceResource(ctx context.Context, k8sClient client.Client, sourceURL, resourceName, refSpec string) error {
|
||||
logger := log.FromContext(ctx)
|
||||
|
||||
// Parse the source URL to determine type
|
||||
sourceType, repoURL, err := parsePlatformSourceURL(sourceURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse platform source URL: %w", err)
|
||||
}
|
||||
|
||||
// Parse reference specification
|
||||
refMap, err := parseRefSpec(refSpec)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse reference specification: %w", err)
|
||||
}
|
||||
|
||||
var obj client.Object
|
||||
switch sourceType {
|
||||
case "oci":
|
||||
obj, err = generateOCIRepository(resourceName, repoURL, refMap)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to generate OCIRepository: %w", err)
|
||||
}
|
||||
case "git":
|
||||
obj, err = generateGitRepository(resourceName, repoURL, refMap)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to generate GitRepository: %w", err)
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("unsupported source type: %s (expected oci:// or https://)", sourceType)
|
||||
}
|
||||
|
||||
// Apply the resource (create or update)
|
||||
logger.Info("Applying platform source resource",
|
||||
"apiVersion", obj.GetObjectKind().GroupVersionKind().GroupVersion().String(),
|
||||
"kind", obj.GetObjectKind().GroupVersionKind().Kind,
|
||||
"name", obj.GetName(),
|
||||
"namespace", obj.GetNamespace(),
|
||||
)
|
||||
|
||||
existing := obj.DeepCopyObject().(client.Object)
|
||||
key := client.ObjectKeyFromObject(obj)
|
||||
|
||||
err = k8sClient.Get(ctx, key, existing)
|
||||
if err != nil {
|
||||
if client.IgnoreNotFound(err) == nil {
|
||||
// Resource doesn't exist, create it
|
||||
if err := k8sClient.Create(ctx, obj); err != nil {
|
||||
return fmt.Errorf("failed to create resource %s/%s: %w", obj.GetObjectKind().GroupVersionKind().Kind, obj.GetName(), err)
|
||||
}
|
||||
logger.Info("Created platform source resource", "kind", obj.GetObjectKind().GroupVersionKind().Kind, "name", obj.GetName())
|
||||
} else {
|
||||
return fmt.Errorf("failed to check if resource exists: %w", err)
|
||||
}
|
||||
} else {
|
||||
// Resource exists, update it
|
||||
obj.SetResourceVersion(existing.GetResourceVersion())
|
||||
if err := k8sClient.Update(ctx, obj); err != nil {
|
||||
return fmt.Errorf("failed to update resource %s/%s: %w", obj.GetObjectKind().GroupVersionKind().Kind, obj.GetName(), err)
|
||||
}
|
||||
logger.Info("Updated platform source resource", "kind", obj.GetObjectKind().GroupVersionKind().Kind, "name", obj.GetName())
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// parsePlatformSourceURL parses the source URL and returns the source type and repository URL.
|
||||
// Supports formats:
|
||||
// - oci://registry.example.com/repo
|
||||
// - https://github.com/user/repo
|
||||
// - http://github.com/user/repo
|
||||
// - ssh://git@github.com/user/repo
|
||||
func parsePlatformSourceURL(sourceURL string) (sourceType, repoURL string, err error) {
|
||||
sourceURL = strings.TrimSpace(sourceURL)
|
||||
|
||||
if strings.HasPrefix(sourceURL, "oci://") {
|
||||
return "oci", sourceURL, nil
|
||||
}
|
||||
|
||||
if strings.HasPrefix(sourceURL, "https://") || strings.HasPrefix(sourceURL, "http://") || strings.HasPrefix(sourceURL, "ssh://") {
|
||||
return "git", sourceURL, nil
|
||||
}
|
||||
|
||||
return "", "", fmt.Errorf("unsupported source URL scheme (expected oci://, https://, http://, or ssh://): %s", sourceURL)
|
||||
}
|
||||
|
||||
// parseRefSpec parses a reference specification string in the format "key1=value1,key2=value2".
|
||||
// Returns a map of key-value pairs.
|
||||
func parseRefSpec(refSpec string) (map[string]string, error) {
|
||||
result := make(map[string]string)
|
||||
|
||||
refSpec = strings.TrimSpace(refSpec)
|
||||
if refSpec == "" {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
pairs := strings.Split(refSpec, ",")
|
||||
for _, pair := range pairs {
|
||||
pair = strings.TrimSpace(pair)
|
||||
if pair == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Split on first '=' only to allow '=' in values (e.g., digest=sha256:...)
|
||||
idx := strings.Index(pair, "=")
|
||||
if idx == -1 {
|
||||
return nil, fmt.Errorf("invalid reference specification format: %q (expected key=value)", pair)
|
||||
}
|
||||
|
||||
key := strings.TrimSpace(pair[:idx])
|
||||
value := strings.TrimSpace(pair[idx+1:])
|
||||
|
||||
if key == "" {
|
||||
return nil, fmt.Errorf("empty key in reference specification: %q", pair)
|
||||
}
|
||||
if value == "" {
|
||||
return nil, fmt.Errorf("empty value for key %q in reference specification", key)
|
||||
}
|
||||
|
||||
result[key] = value
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Valid reference keys for OCI repositories
|
||||
var validOCIRefKeys = map[string]bool{
|
||||
"digest": true,
|
||||
"semver": true,
|
||||
"semverFilter": true,
|
||||
"tag": true,
|
||||
}
|
||||
|
||||
// Valid reference keys for Git repositories
|
||||
var validGitRefKeys = map[string]bool{
|
||||
"branch": true,
|
||||
"tag": true,
|
||||
"semver": true,
|
||||
"name": true,
|
||||
"commit": true,
|
||||
}
|
||||
|
||||
// validateOCIRef validates reference keys for OCI repositories
|
||||
func validateOCIRef(refMap map[string]string) error {
|
||||
for key := range refMap {
|
||||
if !validOCIRefKeys[key] {
|
||||
return fmt.Errorf("invalid OCI reference key %q (valid keys: digest, semver, semverFilter, tag)", key)
|
||||
}
|
||||
}
|
||||
|
||||
// Validate digest format if provided
|
||||
if digest, ok := refMap["digest"]; ok {
|
||||
if !strings.HasPrefix(digest, "sha256:") {
|
||||
return fmt.Errorf("digest must be in format 'sha256:<hash>', got: %s", digest)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateGitRef validates reference keys for Git repositories
|
||||
func validateGitRef(refMap map[string]string) error {
|
||||
for key := range refMap {
|
||||
if !validGitRefKeys[key] {
|
||||
return fmt.Errorf("invalid Git reference key %q (valid keys: branch, tag, semver, name, commit)", key)
|
||||
}
|
||||
}
|
||||
|
||||
// Validate commit format if provided (should be a hex string)
|
||||
if commit, ok := refMap["commit"]; ok {
|
||||
if len(commit) < 7 {
|
||||
return fmt.Errorf("commit SHA should be at least 7 characters, got: %s", commit)
|
||||
}
|
||||
for _, c := range commit {
|
||||
if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) {
|
||||
return fmt.Errorf("commit SHA should be a hexadecimal string, got: %s", commit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// generateOCIRepository creates an OCIRepository resource
|
||||
func generateOCIRepository(name, repoURL string, refMap map[string]string) (*sourcev1.OCIRepository, error) {
|
||||
if err := validateOCIRef(refMap); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
obj := &sourcev1.OCIRepository{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
APIVersion: sourcev1.GroupVersion.String(),
|
||||
Kind: sourcev1.OCIRepositoryKind,
|
||||
},
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: name,
|
||||
Namespace: "cozy-system",
|
||||
},
|
||||
Spec: sourcev1.OCIRepositorySpec{
|
||||
URL: repoURL,
|
||||
Interval: metav1.Duration{Duration: 5 * time.Minute},
|
||||
},
|
||||
}
|
||||
|
||||
// Set reference if any ref options are provided
|
||||
if len(refMap) > 0 {
|
||||
obj.Spec.Reference = &sourcev1.OCIRepositoryRef{
|
||||
Digest: refMap["digest"],
|
||||
SemVer: refMap["semver"],
|
||||
SemverFilter: refMap["semverFilter"],
|
||||
Tag: refMap["tag"],
|
||||
}
|
||||
}
|
||||
|
||||
return obj, nil
|
||||
}
|
||||
|
||||
// generateGitRepository creates a GitRepository resource
|
||||
func generateGitRepository(name, repoURL string, refMap map[string]string) (*sourcev1.GitRepository, error) {
|
||||
if err := validateGitRef(refMap); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
obj := &sourcev1.GitRepository{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
APIVersion: sourcev1.GroupVersion.String(),
|
||||
Kind: sourcev1.GitRepositoryKind,
|
||||
},
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: name,
|
||||
Namespace: "cozy-system",
|
||||
},
|
||||
Spec: sourcev1.GitRepositorySpec{
|
||||
URL: repoURL,
|
||||
Interval: metav1.Duration{Duration: 5 * time.Minute},
|
||||
},
|
||||
}
|
||||
|
||||
// Set reference if any ref options are provided
|
||||
if len(refMap) > 0 {
|
||||
obj.Spec.Reference = &sourcev1.GitRepositoryRef{
|
||||
Branch: refMap["branch"],
|
||||
Tag: refMap["tag"],
|
||||
SemVer: refMap["semver"],
|
||||
Name: refMap["name"],
|
||||
Commit: refMap["commit"],
|
||||
}
|
||||
}
|
||||
|
||||
return obj, nil
|
||||
}
|
||||
@@ -22,7 +22,7 @@ When the user asks to generate a changelog, follow these steps in the specified
|
||||
- [ ] Step 5: Get the list of commits for the release period
|
||||
- [ ] Step 6: Check additional repositories (website is REQUIRED, optional repos if tags exist)
|
||||
- [ ] **MANDATORY**: Check website repository for documentation changes WITH authors and PR links via GitHub CLI
|
||||
- [ ] **MANDATORY**: Check ALL optional repositories (talm, boot-to-talos, cozypkg, cozy-proxy) for tags during release period
|
||||
- [ ] **MANDATORY**: Check ALL optional repositories (talm, boot-to-talos, cozyhr, cozy-proxy) for tags during release period
|
||||
- [ ] **MANDATORY**: For ALL commits from additional repos, get GitHub username via CLI, prioritizing PR author over commit author.
|
||||
- [ ] Step 7: Analyze commits (extract PR numbers, authors, user impact)
|
||||
- [ ] **MANDATORY**: For EVERY PR in main repo, get PR author via `gh pr view <PR_NUMBER> --json author --jq .author.login` (do NOT skip this step)
|
||||
@@ -146,7 +146,7 @@ Cozystack release may include changes from related repositories. Check and inclu
|
||||
**Optional repositories (MUST check ALL of them for tags during release period):**
|
||||
- [https://github.com/cozystack/talm](https://github.com/cozystack/talm)
|
||||
- [https://github.com/cozystack/boot-to-talos](https://github.com/cozystack/boot-to-talos)
|
||||
- [https://github.com/cozystack/cozypkg](https://github.com/cozystack/cozypkg)
|
||||
- [https://github.com/cozystack/cozyhr](https://github.com/cozystack/cozyhr)
|
||||
- [https://github.com/cozystack/cozy-proxy](https://github.com/cozystack/cozy-proxy)
|
||||
|
||||
**⚠️ IMPORTANT**: You MUST check ALL optional repositories for tags created during the release period. Do NOT skip this step even if you think there might not be any tags. Use the process below to verify.
|
||||
@@ -195,7 +195,7 @@ Cozystack release may include changes from related repositories. Check and inclu
|
||||
|
||||
3. **For optional repositories, check if tags exist during release period:**
|
||||
|
||||
**⚠️ MANDATORY: You MUST check ALL optional repositories (talm, boot-to-talos, cozypkg, cozy-proxy). Do NOT skip any repository!**
|
||||
**⚠️ MANDATORY: You MUST check ALL optional repositories (talm, boot-to-talos, cozyhr, cozy-proxy). Do NOT skip any repository!**
|
||||
|
||||
**Use the helper script:**
|
||||
```bash
|
||||
@@ -208,7 +208,7 @@ Cozystack release may include changes from related repositories. Check and inclu
|
||||
```
|
||||
|
||||
The script will:
|
||||
- Check ALL optional repositories (talm, boot-to-talos, cozypkg, cozy-proxy)
|
||||
- Check ALL optional repositories (talm, boot-to-talos, cozyhr, cozy-proxy)
|
||||
- Look for tags created during the release period
|
||||
- Get commits between tags (if tags exist) or by date range (if no tags)
|
||||
- Extract PR numbers from commit messages
|
||||
@@ -569,7 +569,7 @@ Create a new changelog file in the format matching previous versions:
|
||||
- [ ] Step 5 completed: **ALL commits included** (including merge commits and backports) - do not skip any commits
|
||||
- [ ] Step 5 completed: **Backports identified and handled correctly** - original PR author used, both original and backport PR numbers included
|
||||
- [ ] Step 6 completed: Website repository checked for documentation changes WITH authors and PR links via GitHub CLI
|
||||
- [ ] Step 6 completed: **ALL** optional repositories (talm, boot-to-talos, cozypkg, cozy-proxy) checked for tags during release period
|
||||
- [ ] Step 6 completed: **ALL** optional repositories (talm, boot-to-talos, cozyhr, cozy-proxy) checked for tags during release period
|
||||
- [ ] Step 6 completed: For ALL commits from additional repos, GitHub username obtained via GitHub CLI (not skipped). For commits with PR numbers, PR author used via `gh pr view` (not commit author)
|
||||
- [ ] Step 7 completed: For EVERY PR in main repo (including backports), PR author obtained via `gh pr view <PR_NUMBER> --json author --jq .author.login` (not skipped or assumed). Commit author NOT used - always use PR author
|
||||
- [ ] Step 7 completed: **Backports verified** - for each backport PR, original PR found and original PR author used in changelog
|
||||
@@ -628,7 +628,7 @@ Save the changelog to file `docs/changelogs/v<version>.md` according to the vers
|
||||
|
||||
- **Additional repositories (Step 6) - MANDATORY**:
|
||||
- **⚠️ CRITICAL**: Always check the **website** repository for documentation changes during the release period. This is a required step and MUST NOT be skipped.
|
||||
- **⚠️ CRITICAL**: You MUST check ALL optional repositories (talm, boot-to-talos, cozypkg, cozy-proxy) for tags during the release period. Do NOT skip any repository even if you think there might not be tags.
|
||||
- **⚠️ CRITICAL**: You MUST check ALL optional repositories (talm, boot-to-talos, cozyhr, cozy-proxy) for tags during the release period. Do NOT skip any repository even if you think there might not be tags.
|
||||
- **CRITICAL**: For ALL entries from additional repositories (website and optional), you MUST:
|
||||
- **MANDATORY**: Extract PR number from commit message first
|
||||
- **MANDATORY**: For commits with PR numbers, ALWAYS use `gh pr view <PR_NUMBER> --repo cozystack/<repo> --json author --jq .author.login` to get PR author (not commit author)
|
||||
@@ -637,7 +637,7 @@ Save the changelog to file `docs/changelogs/v<version>.md` according to the vers
|
||||
- **MANDATORY**: Do NOT use commit author for PRs - always use PR author
|
||||
- Include PR link or commit hash reference
|
||||
- Format: `* **[repo] Description**: details ([**@username**](https://github.com/username) in cozystack/repo#123)`
|
||||
- For **optional repositories** (talm, boot-to-talos, cozypkg, cozy-proxy), you MUST check ALL of them for tags during the release period. Use the loop provided in Step 6 to check each repository systematically.
|
||||
- For **optional repositories** (talm, boot-to-talos, cozyhr, cozy-proxy), you MUST check ALL of them for tags during the release period. Use the loop provided in Step 6 to check each repository systematically.
|
||||
- When including changes from additional repositories, use the format: `[repo-name] Description` and link to the repository's PR/issue if available
|
||||
- **Prefer PR numbers over commit hashes**: For commits from additional repositories, extract PR number from commit message using GitHub API. Use PR format (`cozystack/website#123`) instead of commit hash (`cozystack/website@abc1234`) when available
|
||||
- **Never add entries without author and PR/commit reference**: Every entry from additional repositories must have both author and link
|
||||
|
||||
@@ -155,6 +155,91 @@ git diff
|
||||
|
||||
The user will commit and push when ready.
|
||||
|
||||
## Code Review Comments
|
||||
|
||||
When asked to fix code review comments, **always work only with unresolved (open) comments**. Resolved comments should be ignored as they have already been addressed.
|
||||
|
||||
### Getting Unresolved Review Comments
|
||||
|
||||
Use GitHub GraphQL API to fetch only unresolved review comments from a pull request:
|
||||
|
||||
```bash
|
||||
gh api graphql -F owner=cozystack -F repo=cozystack -F pr=<PR_NUMBER> -f query='
|
||||
query($owner: String!, $repo: String!, $pr: Int!) {
|
||||
repository(owner: $owner, name: $repo) {
|
||||
pullRequest(number: $pr) {
|
||||
reviewThreads(first: 100) {
|
||||
nodes {
|
||||
isResolved
|
||||
comments(first: 100) {
|
||||
nodes {
|
||||
id
|
||||
path
|
||||
line
|
||||
author { login }
|
||||
bodyText
|
||||
url
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}' --jq '.data.repository.pullRequest.reviewThreads.nodes[] | select(.isResolved == false) | .comments.nodes[]'
|
||||
```
|
||||
|
||||
### Filtering for Unresolved Comments
|
||||
|
||||
The key filter is `select(.isResolved == false)` which ensures only unresolved review threads are processed. Each thread can contain multiple comments, but if the thread is resolved, all its comments should be ignored.
|
||||
|
||||
### Working with Review Comments
|
||||
|
||||
1. **Fetch unresolved comments** using the GraphQL query above
|
||||
2. **Parse the results** to identify:
|
||||
- File path (`path`)
|
||||
- Line number (`line` or `originalLine`)
|
||||
- Comment text (`bodyText`)
|
||||
- Author (`author.login`)
|
||||
3. **Address each unresolved comment** by:
|
||||
- Locating the relevant code section
|
||||
- Making the requested changes
|
||||
- Ensuring the fix addresses the concern raised
|
||||
4. **Do NOT process resolved comments** - they have already been handled
|
||||
|
||||
### Example: Compact List of Unresolved Comments
|
||||
|
||||
For a quick overview of unresolved comments:
|
||||
|
||||
```bash
|
||||
gh api graphql -F owner=cozystack -F repo=cozystack -F pr=<PR_NUMBER> -f query='
|
||||
query($owner: String!, $repo: String!, $pr: Int!) {
|
||||
repository(owner: $owner, name: $repo) {
|
||||
pullRequest(number: $pr) {
|
||||
reviewThreads(first: 100) {
|
||||
nodes {
|
||||
isResolved
|
||||
comments(first: 100) {
|
||||
nodes {
|
||||
path
|
||||
line
|
||||
author { login }
|
||||
bodyText
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}' --jq '.data.repository.pullRequest.reviewThreads.nodes[] | select(.isResolved == false) | .comments.nodes[] | "\(.path):\(.line // "N/A") - \(.author.login): \(.bodyText[:150])"'
|
||||
```
|
||||
|
||||
### Important Notes
|
||||
|
||||
- **REST API limitation**: The REST endpoint `/pulls/{pr}/reviews` returns review summaries, not individual review comments. Use GraphQL API for accessing `reviewThreads` with `isResolved` status.
|
||||
- **Thread-based resolution**: Comments are organized in threads. If a thread is resolved (`isResolved: true`), ignore all comments in that thread.
|
||||
- **Always filter**: Never process comments from resolved threads, even if they appear in the results.
|
||||
|
||||
### Example Workflow
|
||||
|
||||
```bash
|
||||
|
||||
@@ -10,7 +10,7 @@ Cozystack is an open-source Kubernetes-based platform and framework for building
|
||||
- **Multi-tenancy**: Full isolation and self-service for tenants
|
||||
- **GitOps-driven**: FluxCD-based continuous delivery
|
||||
- **Modular Architecture**: Extensible with custom packages and services
|
||||
- **Developer Experience**: Simplified local development with cozypkg tool
|
||||
- **Developer Experience**: Simplified local development with cozyhr tool
|
||||
|
||||
The platform exposes infrastructure services via the Kubernetes API with ready-made configs, built-in monitoring, and alerts.
|
||||
|
||||
|
||||
@@ -5,10 +5,13 @@ https://github.com/cozystack/cozystack/releases/tag/v0.36.2
|
||||
|
||||
## Features and Improvements
|
||||
|
||||
## Security
|
||||
* [vm-disk] New SVG icon for VM disk application. (@kvaps and @kvapsova in https://github.com/cozystack/cozystack/pull/1435)
|
||||
|
||||
## Fixes
|
||||
|
||||
* [kubernetes] Pin CoreDNS image tag to v1.12.4 for consistent, reproducible deployments. (@kvaps in https://github.com/cozystack/cozystack/pull/1469)
|
||||
* [dashboard] Fix FerretDB spec typo that prevented deploy/display in the web UI. (@lllamnyp in https://github.com/cozystack/cozystack/pull/1440)
|
||||
|
||||
## Dependencies
|
||||
|
||||
## Development, Testing, and CI/CD
|
||||
|
||||
13
docs/changelogs/v0.38.2.md
Normal file
13
docs/changelogs/v0.38.2.md
Normal file
@@ -0,0 +1,13 @@
|
||||
|
||||
<!--
|
||||
https://github.com/cozystack/cozystack/releases/tag/v0.38.2
|
||||
-->
|
||||
|
||||
## Fixes
|
||||
|
||||
* **[api] Revert dynamic list kinds representation fix (fixes namespace deletion regression)**: Reverted changes from #1630 that caused a regression affecting namespace deletion and upgrades from previous versions. The regression caused namespace deletion failures with errors like "content is not a list: []unstructured.Unstructured" during namespace finalization. This revert restores compatibility with namespace deletion controller and fixes upgrade issues from previous versions, particularly when running migration 20 ([**@kvaps**](https://github.com/kvaps) in #1677).
|
||||
|
||||
---
|
||||
|
||||
**Full Changelog**: [v0.38.1...v0.38.2](https://github.com/cozystack/cozystack/compare/v0.38.1...v0.38.2)
|
||||
|
||||
14
docs/changelogs/v0.38.3.md
Normal file
14
docs/changelogs/v0.38.3.md
Normal file
@@ -0,0 +1,14 @@
|
||||
<!--
|
||||
https://github.com/cozystack/cozystack/releases/tag/v0.38.3
|
||||
-->
|
||||
|
||||
## Improvements
|
||||
|
||||
* **[core:installer] Address buildx warnings for installer image builds**: Aligns Dockerfile syntax casing to remove buildx warnings, keeping installer builds clean ([**@nbykov0**](https://github.com/nbykov0) in #1682).
|
||||
* **[system:coredns] Align CoreDNS app labels with Talos defaults**: Matches CoreDNS labels to Talos conventions so services select pods consistently across platform and tenant clusters ([**@nbykov0**](https://github.com/nbykov0) in #1675).
|
||||
* **[system:monitoring-agents] Rename CoreDNS metrics service to avoid conflicts**: Renames the metrics service so it no longer clashes with the CoreDNS service used for name resolution in tenant clusters ([**@nbykov0**](https://github.com/nbykov0) in #1676).
|
||||
|
||||
---
|
||||
|
||||
**Full Changelog**: [v0.38.2...v0.38.3](https://github.com/cozystack/cozystack/compare/v0.38.2...v0.38.3)
|
||||
|
||||
18
docs/changelogs/v0.38.4.md
Normal file
18
docs/changelogs/v0.38.4.md
Normal file
@@ -0,0 +1,18 @@
|
||||
<!--
|
||||
https://github.com/cozystack/cozystack/releases/tag/v0.38.4
|
||||
-->
|
||||
|
||||
## Fixes
|
||||
|
||||
* **[linstor] Update piraeus-operator v2.10.2 to handle fsck checks reliably**: Upgrades LINSTOR CSI to avoid failed mounts when fsck sees mounted volumes, improving volume publish reliability ([**@kvaps**](https://github.com/kvaps) in #1689, #1697).
|
||||
* **[dashboard] Nest CustomFormsOverride properties under spec.properties**: Fixes schema generation so custom form properties are placed under `spec.properties`, preventing mis-rendered or missing form fields ([**@kvaps**](https://github.com/kvaps) in #1692, #1700).
|
||||
* **[virtual-machine] Guard PVC resize to only expand storage**: Ensures resize jobs run only when storage size increases, avoiding unintended shrink attempts during VM updates ([**@kvaps**](https://github.com/kvaps) in #1688, #1701).
|
||||
|
||||
## Documentation
|
||||
|
||||
* **[website] Clarify GPU check command**: Makes the kubectl command for validating GPU binding more explicit, including namespace context ([**@nbykov0**](https://github.com/nbykov0) in cozystack/website#379).
|
||||
|
||||
---
|
||||
|
||||
**Full Changelog**: [v0.38.3...v0.38.4](https://github.com/cozystack/cozystack/compare/v0.38.3...v0.38.4)
|
||||
|
||||
95
docs/changelogs/v0.39.0.md
Normal file
95
docs/changelogs/v0.39.0.md
Normal file
@@ -0,0 +1,95 @@
|
||||
# Cozystack v0.39 — "Enhanced Networking & Monitoring"
|
||||
|
||||
This release introduces topology-aware routing for Cilium services, automatic pod rollouts on configuration changes, improved monitoring capabilities, and numerous bug fixes and improvements across the platform.
|
||||
|
||||
## Highlights
|
||||
|
||||
* **Topology-Aware Routing**: Enabled topology-aware routing for Cilium services, improving traffic distribution and reducing latency by routing traffic to endpoints in the same zone when possible ([**@nbykov0**](https://github.com/nbykov0) in #1734).
|
||||
* **Automatic Pod Rollouts**: Cilium and Cilium operator pods now automatically restart when configuration changes, ensuring configuration updates are applied immediately ([**@kvaps**](https://github.com/kvaps) in #1728).
|
||||
* **Windows VM Scheduling**: Added nodeAffinity configuration for Windows VMs based on scheduling config, enabling dedicated nodes for Windows workloads ([**@kvaps**](https://github.com/kvaps) in #1693).
|
||||
* **SeaweedFS Updates**: Updated to SeaweedFS v4.02 with improved S3 daemon performance and fixes ([**@kvaps**](https://github.com/kvaps) in #1725).
|
||||
|
||||
---
|
||||
|
||||
## Major Features and Improvements
|
||||
|
||||
### Networking
|
||||
|
||||
* **[system/cilium] Enable topology-aware routing for services**: Enabled topology-aware routing for services, improving traffic distribution and reducing latency by routing traffic to endpoints in the same zone when possible. This feature helps optimize network performance in multi-zone deployments ([**@nbykov0**](https://github.com/nbykov0) in #1734).
|
||||
* **[cilium] Enable automatic pod rollout on configmap updates**: Cilium and Cilium operator pods now automatically restart when the cilium-config ConfigMap is updated, ensuring configuration changes are applied immediately without manual intervention ([**@kvaps**](https://github.com/kvaps) in #1728).
|
||||
|
||||
### Virtual Machines
|
||||
|
||||
* **[virtual-machine,vm-instance] Add nodeAffinity for Windows VMs based on scheduling config**: Added nodeAffinity configuration to virtual-machine and vm-instance charts to support dedicated nodes for Windows VMs. When `dedicatedNodesForWindowsVMs` is enabled in the `cozystack-scheduling` ConfigMap, Windows VMs are scheduled on nodes with label `scheduling.cozystack.io/vm-windows=true`, while non-Windows VMs prefer nodes without this label ([**@kvaps**](https://github.com/kvaps) in #1693).
|
||||
|
||||
### Storage
|
||||
|
||||
* **Update SeaweedFS v4.02**: Updated SeaweedFS to version 4.02 with improved performance for S3 daemon and fixes for known issues. This update includes better S3 compatibility and performance improvements ([**@kvaps**](https://github.com/kvaps) in #1725).
|
||||
|
||||
### Tools
|
||||
|
||||
* **[talm] feat(init)!: require --name flag for cluster name**: Breaking change: The `talm init` command now requires the `--name` flag to specify the cluster name. This ensures consistent cluster naming and prevents accidental initialization without a name ([**@lexfrei**](https://github.com/lexfrei) in cozystack/talm#86).
|
||||
* **[talm] feat(template): preserve extra YAML documents in output**: Templates now preserve extra YAML documents in the output, allowing for more flexible template processing ([**@lexfrei**](https://github.com/lexfrei) in cozystack/talm#87).
|
||||
* **[talm] feat: add directory expansion for -f flag**: Added directory expansion support for the `-f` flag, allowing users to specify directories instead of individual files ([**@kvaps**](https://github.com/kvaps) in cozystack/talm@ca5713e).
|
||||
* **[talm] Introduce automatic root detection**: Added automatic root detection logic to simplify talm usage and reduce manual configuration ([**@kvaps**](https://github.com/kvaps) in cozystack/talm@d165162).
|
||||
* **[talm] Introduce talm kubeconfig --login command**: Added new `talm kubeconfig --login` command for easier kubeconfig management ([**@kvaps**](https://github.com/kvaps) in cozystack/talm@5f7e05b).
|
||||
* **[talm] Introduce encryption**: Added encryption support to talm for secure configuration management ([**@kvaps**](https://github.com/kvaps) in cozystack/talm#81).
|
||||
* **[talm] Replace code-generation with wrapper on talosctl**: Refactored talm to use a wrapper on talosctl instead of code generation, simplifying the codebase and improving maintainability ([**@kvaps**](https://github.com/kvaps) in cozystack/talm#80).
|
||||
* **[talm] Use go embed instead of code generation**: Migrated from code generation to go embed for better build performance and simpler dependency management ([**@kvaps**](https://github.com/kvaps) in cozystack/talm#79).
|
||||
* **[boot-to-talos] Cozystack: Update Talos Linux v1.11.3**: Updated boot-to-talos to use Talos Linux v1.11.3 ([**@kvaps**](https://github.com/kvaps) in cozystack/boot-to-talos#7).
|
||||
|
||||
## Improvements
|
||||
|
||||
* **[seaweedfs] Extended CA certificate duration to reduce disruptive CA rotations**: Extended CA certificate duration to reduce disruptive CA rotations, improving long-term certificate management and reducing operational overhead ([**@IvanHunters**](https://github.com/IvanHunters) in #1657).
|
||||
* **[dashboard] Add config hash annotations to restart pods on config changes**: Added config hash annotations to dashboard deployment templates to ensure pods are automatically restarted when their configuration changes, ensuring configuration updates are applied immediately ([**@kvaps**](https://github.com/kvaps) in #1662).
|
||||
* **[tenant][kubernetes] Introduce better cleanup logic**: Improved cleanup logic for tenant Kubernetes resources, ensuring proper resource cleanup when tenants are deleted or updated. Added automated pre-delete cleanup job for tenant namespaces to remove tenant-related releases during uninstall ([**@kvaps**](https://github.com/kvaps) in #1661).
|
||||
* **[system:coredns] update coredns app labels to match Talos coredns labels**: Updated coredns app labels to match Talos coredns labels, ensuring consistency across the platform ([**@nbykov0**](https://github.com/nbykov0) in #1675).
|
||||
* **[system:monitoring-agents] rename coredns metrics service**: Renamed coredns metrics service to avoid interference with coredns service used for name resolution in tenant k8s clusters ([**@nbykov0**](https://github.com/nbykov0) in #1676).
|
||||
* **[core:installer] Address buildx warnings**: Fixed Dockerfile syntax warnings from buildx, ensuring clean builds without warnings ([**@nbykov0**](https://github.com/nbykov0) in #1682).
|
||||
* **[talm] Refactor root detection logic into single file**: Improved code organization by consolidating root detection logic into a single file ([**@kvaps**](https://github.com/kvaps) in cozystack/talm@487b479).
|
||||
* **[talm] Refactor init logic, better upgrade**: Improved initialization logic and upgrade process for better reliability ([**@kvaps**](https://github.com/kvaps) in cozystack/talm@c512777).
|
||||
* **[talm] Sugar for kubeconfig command**: Added convenience features to the kubeconfig command for improved usability ([**@kvaps**](https://github.com/kvaps) in cozystack/talm@a4010b3).
|
||||
* **[talm] wrap upgrade command**: Wrapped upgrade command for better integration and error handling ([**@kvaps**](https://github.com/kvaps) in cozystack/talm@2e1afbf).
|
||||
* **[talm] docs(readme): add Homebrew installation option**: Added Homebrew installation option to the README for easier installation on macOS ([**@lexfrei**](https://github.com/lexfrei) in cozystack/talm@12bd4f2).
|
||||
* **[talm] cozystack: disable nodeCIDRs allocation**: Disabled nodeCIDRs allocation in talm for better network configuration control ([**@kvaps**](https://github.com/kvaps) in cozystack/talm#82).
|
||||
* **[talm] Update license to Apache2.0**: Updated license to Apache 2.0 for better compatibility and clarity ([**@kvaps**](https://github.com/kvaps) in cozystack/talm@eda1032).
|
||||
|
||||
## Fixes
|
||||
|
||||
* **[apps] Refactor apiserver to use typed objects and fix UnstructuredList GVK**: Refactored the apiserver REST handlers to use typed objects (`appsv1alpha1.Application`) instead of `unstructured.Unstructured`, eliminating the need for runtime conversions and simplifying the codebase. Additionally, fixed an issue where `UnstructuredList` objects were using the first registered kind from `typeToGVK` instead of the kind from the object's field when multiple kinds are registered with the same Go type. This fix includes the upstream fix from kubernetes/kubernetes#135537 ([**@kvaps**](https://github.com/kvaps) in #1679).
|
||||
* **[dashboard] Fix CustomFormsOverride schema to nest properties under spec.properties**: Fixed the logic for generating CustomFormsOverride schema to properly nest properties under `spec.properties` instead of directly under `properties`, ensuring correct form schema generation ([**@kvaps**](https://github.com/kvaps) in #1692).
|
||||
* **[virtual-machine] Improve check for resizing job**: Improved storage resize logic to only expand persistent volume claims when storage is being increased, preventing unintended storage reduction operations. Added validation to accurately compare current and desired storage sizes before triggering resize operations ([**@kvaps**](https://github.com/kvaps) in #1688).
|
||||
* **[linstor] Update piraeus-operator v2.10.2**: Updated LINSTOR CSI to fix issues with the new fsck behaviour, resolving mount failures when fsck attempts to run on mounted devices ([**@kvaps**](https://github.com/kvaps) in #1689).
|
||||
* **[api] Revert dynamic list kinds representation fix (fixes namespace deletion regression)**: Reverted changes from #1630 that caused a regression affecting namespace deletion and upgrades from previous versions. The regression caused namespace deletion failures with errors like "content is not a list: []unstructured.Unstructured" during namespace finalization. This revert restores compatibility with namespace deletion controller and fixes upgrade issues from previous versions ([**@kvaps**](https://github.com/kvaps) in #1677).
|
||||
* **[talm] fix: normalize template paths for Windows compatibility**: Fixed template path handling to ensure Windows compatibility by normalizing paths ([**@lexfrei**](https://github.com/lexfrei) in cozystack/talm#88).
|
||||
|
||||
## Dependencies
|
||||
|
||||
* **Update SeaweedFS v4.02**: Updated SeaweedFS to version 4.02 ([**@kvaps**](https://github.com/kvaps) in #1725).
|
||||
* **[linstor] Update piraeus-operator v2.10.2**: Updated piraeus-operator to version 2.10.2 ([**@kvaps**](https://github.com/kvaps) in #1689).
|
||||
* **[talm] Cozystack: Update Talos Linux v1.11.3**: Updated talm to use Talos Linux v1.11.3 ([**@kvaps**](https://github.com/kvaps) in cozystack/talm#83).
|
||||
|
||||
## Documentation
|
||||
|
||||
* **[website] Add article: Talm v0.17: Built-in Age Encryption for Secrets Management**: Added comprehensive blog post announcing Talm v0.17 and its built-in age-based encryption for secrets. Covers initial setup and key generation, encryption/decryption workflows, idempotent encryption behavior, automatic .gitignore handling, file permission safeguards, security best practices, and guidance for GitOps and CI/CD integration ([**@kvaps**](https://github.com/kvaps) in [cozystack/website#384](https://github.com/cozystack/website/pull/384)).
|
||||
* **[website] docs(talm): update talm init syntax for mandatory --preset and --name flags**: Updated documentation to reflect breaking changes in talm, adding mandatory `--preset` and `--name` flags to the talm init command ([**@lexfrei**](https://github.com/lexfrei) in cozystack/website#386).
|
||||
|
||||
---
|
||||
|
||||
## Contributors
|
||||
|
||||
We'd like to thank all contributors who made this release possible:
|
||||
|
||||
* [**@IvanHunters**](https://github.com/IvanHunters)
|
||||
* [**@kvaps**](https://github.com/kvaps)
|
||||
* [**@lexfrei**](https://github.com/lexfrei)
|
||||
* [**@nbykov0**](https://github.com/nbykov0)
|
||||
|
||||
---
|
||||
|
||||
**Full Changelog**: [v0.38.0...v0.39.0](https://github.com/cozystack/cozystack/compare/v0.38.0...v0.39.0)
|
||||
|
||||
<!--
|
||||
https://github.com/cozystack/cozystack/releases/tag/v0.39.0
|
||||
-->
|
||||
|
||||
12
docs/changelogs/v0.39.1.md
Normal file
12
docs/changelogs/v0.39.1.md
Normal file
@@ -0,0 +1,12 @@
|
||||
<!--
|
||||
https://github.com/cozystack/cozystack/releases/tag/v0.39.1
|
||||
-->
|
||||
|
||||
## Features and Improvements
|
||||
|
||||
* **[monitoring] Add SLACK_SEVERITY_FILTER field and VMAgent for tenant monitoring**: Introduced the SLACK_SEVERITY_FILTER environment variable in the Alerta deployment to enable filtering of alert severities for Slack notifications based on the disabledSeverity configuration. Additionally, added a VMAgent resource template for scraping metrics within tenant namespaces, improving monitoring granularity and control. This enhancement allows administrators to configure which alert severities are sent to Slack and enables tenant-specific metrics collection for better observability ([**@IvanHunters**](https://github.com/IvanHunters) in #1712).
|
||||
|
||||
---
|
||||
|
||||
**Full Changelog**: [v0.39.0...v0.39.1](https://github.com/cozystack/cozystack/compare/v0.39.0...v0.39.1)
|
||||
|
||||
20
examples/desired-backup.yaml
Normal file
20
examples/desired-backup.yaml
Normal file
@@ -0,0 +1,20 @@
|
||||
apiVersion: backups.cozystack.io/v1alpha1
|
||||
kind: BackupJob
|
||||
metadata:
|
||||
name: desired-backup
|
||||
namespace: tenant-root
|
||||
labels:
|
||||
backups.cozystack.io/triggered-by: manual
|
||||
spec:
|
||||
applicationRef:
|
||||
apiGroup: apps.cozystack.io
|
||||
kind: VirtualMachine
|
||||
name: vm1
|
||||
storageRef:
|
||||
apiGroup: apps.cozystack.io
|
||||
kind: Bucket
|
||||
name: test-bucket
|
||||
strategyRef:
|
||||
apiGroup: strategy.backups.cozystack.io
|
||||
kind: Velero
|
||||
name: velero-strategy-default
|
||||
155
go.mod
155
go.mod
@@ -2,33 +2,41 @@
|
||||
|
||||
module github.com/cozystack/cozystack
|
||||
|
||||
go 1.23.0
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/fluxcd/helm-controller/api v1.1.0
|
||||
github.com/emicklei/dot v1.10.0
|
||||
github.com/fluxcd/helm-controller/api v1.4.3
|
||||
github.com/fluxcd/source-controller/api v1.7.4
|
||||
github.com/fluxcd/source-watcher/api/v2 v2.0.3
|
||||
github.com/go-logr/logr v1.4.3
|
||||
github.com/go-logr/zapr v1.3.0
|
||||
github.com/google/gofuzz v1.2.0
|
||||
github.com/onsi/ginkgo/v2 v2.19.0
|
||||
github.com/onsi/gomega v1.33.1
|
||||
github.com/spf13/cobra v1.8.1
|
||||
github.com/stretchr/testify v1.9.0
|
||||
github.com/onsi/ginkgo/v2 v2.23.3
|
||||
github.com/onsi/gomega v1.37.0
|
||||
github.com/prometheus/client_golang v1.22.0
|
||||
github.com/robfig/cron/v3 v3.0.1
|
||||
github.com/spf13/cobra v1.9.1
|
||||
github.com/vmware-tanzu/velero v1.17.1
|
||||
go.uber.org/zap v1.27.0
|
||||
gopkg.in/yaml.v2 v2.4.0
|
||||
k8s.io/api v0.31.2
|
||||
k8s.io/apiextensions-apiserver v0.31.2
|
||||
k8s.io/apimachinery v0.31.2
|
||||
k8s.io/apiserver v0.31.2
|
||||
k8s.io/client-go v0.31.2
|
||||
k8s.io/component-base v0.31.2
|
||||
k8s.io/api v0.34.1
|
||||
k8s.io/apiextensions-apiserver v0.34.1
|
||||
k8s.io/apimachinery v0.34.2
|
||||
k8s.io/apiserver v0.34.1
|
||||
k8s.io/client-go v0.34.1
|
||||
k8s.io/component-base v0.34.1
|
||||
k8s.io/klog/v2 v2.130.1
|
||||
k8s.io/kube-openapi v0.0.0-20240827152857-f7e401e7b4c2
|
||||
k8s.io/utils v0.0.0-20240711033017-18e509b52bc8
|
||||
sigs.k8s.io/controller-runtime v0.19.0
|
||||
sigs.k8s.io/structured-merge-diff/v4 v4.4.1
|
||||
k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b
|
||||
k8s.io/utils v0.0.0-20250820121507-0af2bda4dd1d
|
||||
sigs.k8s.io/controller-runtime v0.22.4
|
||||
sigs.k8s.io/structured-merge-diff/v4 v4.7.0
|
||||
)
|
||||
|
||||
require (
|
||||
cel.dev/expr v0.24.0 // indirect
|
||||
github.com/NYTimes/gziphandler v1.1.1 // indirect
|
||||
github.com/antlr4-go/antlr/v4 v4.13.0 // indirect
|
||||
github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/blang/semver/v4 v4.0.0 // indirect
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
||||
@@ -36,86 +44,91 @@ require (
|
||||
github.com/coreos/go-semver v0.3.1 // indirect
|
||||
github.com/coreos/go-systemd/v22 v22.5.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/emicklei/go-restful/v3 v3.11.0 // indirect
|
||||
github.com/emicklei/go-restful/v3 v3.12.2 // indirect
|
||||
github.com/evanphx/json-patch v4.12.0+incompatible // indirect
|
||||
github.com/evanphx/json-patch/v5 v5.9.0 // indirect
|
||||
github.com/evanphx/json-patch/v5 v5.9.11 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/fluxcd/pkg/apis/kustomize v1.6.1 // indirect
|
||||
github.com/fluxcd/pkg/apis/meta v1.6.1 // indirect
|
||||
github.com/fsnotify/fsnotify v1.7.0 // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.7.0 // indirect
|
||||
github.com/go-logr/logr v1.4.2 // indirect
|
||||
github.com/fluxcd/pkg/apis/acl v0.9.0 // indirect
|
||||
github.com/fluxcd/pkg/apis/kustomize v1.13.0 // indirect
|
||||
github.com/fluxcd/pkg/apis/meta v1.23.0 // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-logr/zapr v1.3.0 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.21.0 // indirect
|
||||
github.com/go-openapi/jsonreference v0.20.2 // indirect
|
||||
github.com/go-openapi/jsonreference v0.21.0 // indirect
|
||||
github.com/go-openapi/swag v0.23.0 // indirect
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
|
||||
github.com/gogo/protobuf v1.3.2 // indirect
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
||||
github.com/golang/protobuf v1.5.4 // indirect
|
||||
github.com/google/cel-go v0.21.0 // indirect
|
||||
github.com/google/gnostic-models v0.6.8 // indirect
|
||||
github.com/google/go-cmp v0.6.0 // indirect
|
||||
github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8 // indirect
|
||||
github.com/google/btree v1.1.3 // indirect
|
||||
github.com/google/cel-go v0.26.0 // indirect
|
||||
github.com/google/gnostic-models v0.7.0 // indirect
|
||||
github.com/google/go-cmp v0.7.0 // indirect
|
||||
github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/gorilla/websocket v1.5.0 // indirect
|
||||
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect
|
||||
github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 // indirect
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0 // indirect
|
||||
github.com/imdario/mergo v0.3.6 // indirect
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/mailru/easyjson v0.7.7 // indirect
|
||||
github.com/moby/spdystream v0.4.0 // indirect
|
||||
github.com/kylelemons/godebug v1.1.0 // indirect
|
||||
github.com/mailru/easyjson v0.9.0 // indirect
|
||||
github.com/moby/spdystream v0.5.0 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/prometheus/client_golang v1.19.1 // indirect
|
||||
github.com/prometheus/client_model v0.6.1 // indirect
|
||||
github.com/prometheus/common v0.55.0 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.65.0 // indirect
|
||||
github.com/prometheus/procfs v0.15.1 // indirect
|
||||
github.com/spf13/pflag v1.0.5 // indirect
|
||||
github.com/spf13/pflag v1.0.7 // indirect
|
||||
github.com/stoewer/go-strcase v1.3.0 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
go.etcd.io/etcd/api/v3 v3.5.16 // indirect
|
||||
go.etcd.io/etcd/client/pkg/v3 v3.5.16 // indirect
|
||||
go.etcd.io/etcd/client/v3 v3.5.16 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.53.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.53.0 // indirect
|
||||
go.opentelemetry.io/otel v1.28.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.28.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.27.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.28.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk v1.28.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.28.0 // indirect
|
||||
go.opentelemetry.io/proto/otlp v1.3.1 // indirect
|
||||
go.etcd.io/etcd/api/v3 v3.6.4 // indirect
|
||||
go.etcd.io/etcd/client/pkg/v3 v3.6.4 // indirect
|
||||
go.etcd.io/etcd/client/v3 v3.6.4 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect
|
||||
go.opentelemetry.io/otel v1.37.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.34.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.37.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk v1.37.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.37.0 // indirect
|
||||
go.opentelemetry.io/proto/otlp v1.5.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
go.uber.org/zap v1.27.0 // indirect
|
||||
golang.org/x/crypto v0.31.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.2 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/crypto v0.42.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect
|
||||
golang.org/x/net v0.33.0 // indirect
|
||||
golang.org/x/oauth2 v0.23.0 // indirect
|
||||
golang.org/x/sync v0.10.0 // indirect
|
||||
golang.org/x/sys v0.28.0 // indirect
|
||||
golang.org/x/term v0.27.0 // indirect
|
||||
golang.org/x/text v0.21.0 // indirect
|
||||
golang.org/x/time v0.7.0 // indirect
|
||||
golang.org/x/tools v0.26.0 // indirect
|
||||
golang.org/x/net v0.45.0 // indirect
|
||||
golang.org/x/oauth2 v0.30.0 // indirect
|
||||
golang.org/x/sync v0.17.0 // indirect
|
||||
golang.org/x/sys v0.36.0 // indirect
|
||||
golang.org/x/term v0.35.0 // indirect
|
||||
golang.org/x/text v0.29.0 // indirect
|
||||
golang.org/x/time v0.12.0 // indirect
|
||||
golang.org/x/tools v0.37.0 // indirect
|
||||
gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20240528184218-531527333157 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240701130421-f6361c86f094 // indirect
|
||||
google.golang.org/grpc v1.65.0 // indirect
|
||||
google.golang.org/protobuf v1.34.2 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect
|
||||
google.golang.org/grpc v1.73.0 // indirect
|
||||
google.golang.org/protobuf v1.36.6 // indirect
|
||||
gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect
|
||||
gopkg.in/inf.v0 v0.9.1 // indirect
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
k8s.io/kms v0.31.2 // indirect
|
||||
sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.0 // indirect
|
||||
sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 // indirect
|
||||
sigs.k8s.io/yaml v1.4.0 // indirect
|
||||
k8s.io/kms v0.34.1 // indirect
|
||||
sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2 // indirect
|
||||
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect
|
||||
sigs.k8s.io/randfill v1.0.0 // indirect
|
||||
sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect
|
||||
sigs.k8s.io/yaml v1.6.0 // indirect
|
||||
)
|
||||
|
||||
// See: issues.k8s.io/135537
|
||||
replace k8s.io/apimachinery => github.com/cozystack/apimachinery v0.0.0-20251219010959-1f91eabae46c
|
||||
|
||||
355
go.sum
355
go.sum
@@ -1,11 +1,11 @@
|
||||
cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY=
|
||||
cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw=
|
||||
github.com/NYTimes/gziphandler v1.1.1 h1:ZUDjpQae29j0ryrS0u/B8HZfJBtBQHjqw2rQ2cqUQ3I=
|
||||
github.com/NYTimes/gziphandler v1.1.1/go.mod h1:n/CVRwUEOgIxrgPvAQhUUr9oeUtvrhMomdKFjzJNB0c=
|
||||
github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI=
|
||||
github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g=
|
||||
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
|
||||
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
|
||||
github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a h1:idn718Q4B6AGu/h5Sxe66HYVdqdGu2l9Iebqhi/AEoA=
|
||||
github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a/go.mod h1:lB+ZfQJz7igIIfQNfa7Ml4HSf2uFQQRzpGGRXenZAgY=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM=
|
||||
@@ -18,45 +18,52 @@ github.com/coreos/go-semver v0.3.1 h1:yi21YpKnrx1gt5R+la8n5WgS0kCrsPp33dmEyHReZr
|
||||
github.com/coreos/go-semver v0.3.1/go.mod h1:irMmmIw/7yzSRPWryHsK7EYSg09caPQL03VsM8rvUec=
|
||||
github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs=
|
||||
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/cozystack/apimachinery v0.0.0-20251219010959-1f91eabae46c h1:C2wIfH/OzhU9XOK/e6Ik9cg7nZ1z6fN4lf6a3yFdik8=
|
||||
github.com/cozystack/apimachinery v0.0.0-20251219010959-1f91eabae46c/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g=
|
||||
github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
|
||||
github.com/emicklei/dot v1.10.0 h1:z17n0ce/FBMz3QbShSzVGhiW447Qhu7fljzvp3Gs6ig=
|
||||
github.com/emicklei/dot v1.10.0/go.mod h1:DeV7GvQtIw4h2u73RKBkkFdvVAz0D9fzeJrgPW6gy/s=
|
||||
github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU=
|
||||
github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
|
||||
github.com/evanphx/json-patch v4.12.0+incompatible h1:4onqiflcdA9EOZ4RxV643DvftH5pOlLGNtQ5lPWQu84=
|
||||
github.com/evanphx/json-patch v4.12.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk=
|
||||
github.com/evanphx/json-patch/v5 v5.9.0 h1:kcBlZQbplgElYIlo/n1hJbls2z/1awpXxpRi0/FOJfg=
|
||||
github.com/evanphx/json-patch/v5 v5.9.0/go.mod h1:VNkHZ/282BpEyt/tObQO8s5CMPmYYq14uClGH4abBuQ=
|
||||
github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU=
|
||||
github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/fluxcd/helm-controller/api v1.1.0 h1:NS5Wm3U6Kv4w7Cw2sDOV++vf2ecGfFV00x1+2Y3QcOY=
|
||||
github.com/fluxcd/helm-controller/api v1.1.0/go.mod h1:BgHMgMY6CWynzl4KIbHpd6Wpn3FN9BqgkwmvoKCp6iE=
|
||||
github.com/fluxcd/pkg/apis/kustomize v1.6.1 h1:22FJc69Mq4i8aCxnKPlddHhSMyI4UPkQkqiAdWFcqe0=
|
||||
github.com/fluxcd/pkg/apis/kustomize v1.6.1/go.mod h1:5dvQ4IZwz0hMGmuj8tTWGtarsuxW0rWsxJOwC6i+0V8=
|
||||
github.com/fluxcd/pkg/apis/meta v1.6.1 h1:maLhcRJ3P/70ArLCY/LF/YovkxXbX+6sTWZwZQBeNq0=
|
||||
github.com/fluxcd/pkg/apis/meta v1.6.1/go.mod h1:YndB/gxgGZmKfqpAfFxyCDNFJFP0ikpeJzs66jwq280=
|
||||
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
|
||||
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
|
||||
github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E=
|
||||
github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ=
|
||||
github.com/fluxcd/helm-controller/api v1.4.3 h1:CdZwjL1liXmYCWyk2jscmFEB59tICIlnWB9PfDDW5q4=
|
||||
github.com/fluxcd/helm-controller/api v1.4.3/go.mod h1:0XrBhKEaqvxyDj/FziG1Q8Fmx2UATdaqLgYqmZh6wW4=
|
||||
github.com/fluxcd/pkg/apis/acl v0.9.0 h1:wBpgsKT+jcyZEcM//OmZr9RiF8klL3ebrDp2u2ThsnA=
|
||||
github.com/fluxcd/pkg/apis/acl v0.9.0/go.mod h1:TttNS+gocsGLwnvmgVi3/Yscwqrjc17+vhgYfqkfrV4=
|
||||
github.com/fluxcd/pkg/apis/kustomize v1.13.0 h1:GGf0UBVRIku+gebY944icVeEIhyg1P/KE3IrhOyJJnE=
|
||||
github.com/fluxcd/pkg/apis/kustomize v1.13.0/go.mod h1:TLKVqbtnzkhDuhWnAsN35977HvRfIjs+lgMuNro/LEc=
|
||||
github.com/fluxcd/pkg/apis/meta v1.23.0 h1:fLis5YcHnOsyKYptzBtituBm5EWNx13I0bXQsy0FG4s=
|
||||
github.com/fluxcd/pkg/apis/meta v1.23.0/go.mod h1:UWsIbBPCxYvoVklr2mV2uLFBf/n17dNAmKFjRfApdDo=
|
||||
github.com/fluxcd/source-controller/api v1.7.4 h1:+EOVnRA9LmLxOx7J273l7IOEU39m+Slt/nQGBy69ygs=
|
||||
github.com/fluxcd/source-controller/api v1.7.4/go.mod h1:ruf49LEgZRBfcP+eshl2n9SX1MfHayCcViAIGnZcaDY=
|
||||
github.com/fluxcd/source-watcher/api/v2 v2.0.3 h1:SsVGAaMBxzvcgrOz/Kl6c2ybMHVqoiEFwtI+bDuSeSs=
|
||||
github.com/fluxcd/source-watcher/api/v2 v2.0.3/go.mod h1:Nx3QZweVyuhaOtSNrw+oxifG+qrakPvjgNAN9qlUTb0=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
|
||||
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
||||
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ=
|
||||
github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg=
|
||||
github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs=
|
||||
github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
|
||||
github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
|
||||
github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE=
|
||||
github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k=
|
||||
github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14=
|
||||
github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ=
|
||||
github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4=
|
||||
github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
|
||||
github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
|
||||
@@ -64,162 +71,173 @@ github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZ
|
||||
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg=
|
||||
github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/btree v1.0.1 h1:gK4Kx5IaGY9CD5sPJ36FHiBJ6ZXl0kilRiiCj+jdYp4=
|
||||
github.com/google/btree v1.0.1/go.mod h1:xXMiIv4Fb/0kKde4SpL7qlzvu5cMJDRkFDxJfI9uaxA=
|
||||
github.com/google/cel-go v0.21.0 h1:cl6uW/gxN+Hy50tNYvI691+sXxioCnstFzLp2WO4GCI=
|
||||
github.com/google/cel-go v0.21.0/go.mod h1:rHUlWCcBKgyEk+eV03RPdZUekPp6YcJwV0FxuUksYxc=
|
||||
github.com/google/gnostic-models v0.6.8 h1:yo/ABAfM5IMRsS1VnXjTBvUb61tFIHozhlYvRgGre9I=
|
||||
github.com/google/gnostic-models v0.6.8/go.mod h1:5n7qKqH0f5wFt+aWF8CW6pZLLNOfYuF5OpfBSENuI8U=
|
||||
github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg=
|
||||
github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
|
||||
github.com/google/cel-go v0.26.0 h1:DPGjXackMpJWH680oGY4lZhYjIameYmR+/6RBdDGmaI=
|
||||
github.com/google/cel-go v0.26.0/go.mod h1:A9O8OU9rdvrK5MQyrqfIxo1a0u4g3sF8KB6PUIaryMM=
|
||||
github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo=
|
||||
github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ=
|
||||
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
|
||||
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8 h1:FKHo8hFI3A+7w0aUQuYXQ+6EN5stWmeY/AZqtM8xk9k=
|
||||
github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8/go.mod h1:K1liHPHnj73Fdn/EKuT8nrFqBihUSKXoLYU0BuatOYo=
|
||||
github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad h1:a6HEuzUHeKH6hwfN/ZoQgRgVIWFJljSWa/zetS2WTvg=
|
||||
github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
|
||||
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/grpc-ecosystem/go-grpc-middleware v1.3.0 h1:+9834+KizmvFV7pXQGSXQTsaWhq2GjuNUt0aUU0YBYw=
|
||||
github.com/grpc-ecosystem/go-grpc-middleware v1.3.0/go.mod h1:z0ButlSOZa5vEBq9m2m2hlwIgKw+rp3sdCBRoJY+30Y=
|
||||
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo=
|
||||
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=
|
||||
github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.0.1 h1:qnpSQwGEnkcRpTqNOIR6bJbR0gAorgP9CSALpRcKoAA=
|
||||
github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.0.1/go.mod h1:lXGCsh6c22WGtjr+qGHj1otzZpV/1kwTMAqkwZsnWRU=
|
||||
github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.0 h1:FbSCl+KggFl+Ocym490i/EyXF4lPgLoUtcSWquBM0Rs=
|
||||
github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.0/go.mod h1:qOchhhIlmRcqk/O9uCo/puJlyo07YINaIqdZfZG3Jkc=
|
||||
github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 h1:Ovs26xHkKqVztRpIrF/92BcuyuQ/YW4NSIpoGtfXNho=
|
||||
github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk=
|
||||
github.com/grpc-ecosystem/grpc-gateway v1.16.0 h1:gmcG1KaJ57LophUzW0Hy8NmPhnMZb4M0+kPpLofRdBo=
|
||||
github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0 h1:bkypFPDjIYGfCYD5mRBvpqxfYX1YCS1PXdKYWi8FsN0=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0/go.mod h1:P+Lt/0by1T8bfcF3z737NnSbmxQAppXMRziHUxPOC8k=
|
||||
github.com/imdario/mergo v0.3.6 h1:xTNEAn+kxVO7dTZGu0CegyqKZmoWFI0rF8UxjlB2d28=
|
||||
github.com/imdario/mergo v0.3.6/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/jonboulle/clockwork v0.2.2 h1:UOGuzwb1PwsrDAObMuhUnj0p5ULPj8V/xJ7Kx9qUBdQ=
|
||||
github.com/jonboulle/clockwork v0.2.2/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUBGnn1kMkgxc8=
|
||||
github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I=
|
||||
github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60=
|
||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
|
||||
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||
github.com/moby/spdystream v0.4.0 h1:Vy79D6mHeJJjiPdFEL2yku1kl0chZpJfZcPpb16BRl8=
|
||||
github.com/moby/spdystream v0.4.0/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4=
|
||||
github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
|
||||
github.com/moby/spdystream v0.5.0 h1:7r0J1Si3QO/kjRitvSLVVFUjxMEb/YLj6S9FF62JBCU=
|
||||
github.com/moby/spdystream v0.5.0/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8=
|
||||
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f h1:y5//uYreIhSUg3J1GEMiLbxo1LJaP8RfCpH6pymGZus=
|
||||
github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw=
|
||||
github.com/onsi/ginkgo/v2 v2.19.0 h1:9Cnnf7UHo57Hy3k6/m5k3dRfGTMXGvxhHFvkDTCTpvA=
|
||||
github.com/onsi/ginkgo/v2 v2.19.0/go.mod h1:rlwLi9PilAFJ8jCg9UE1QP6VBpd6/xj3SRC0d6TU0To=
|
||||
github.com/onsi/gomega v1.33.1 h1:dsYjIxxSR755MDmKVsaFQTE22ChNBcuuTWgkUDSubOk=
|
||||
github.com/onsi/gomega v1.33.1/go.mod h1:U4R44UsT+9eLIaYRB2a5qajjtQYn0hauxvRm16AVYg0=
|
||||
github.com/onsi/ginkgo/v2 v2.23.3 h1:edHxnszytJ4lD9D5Jjc4tiDkPBZ3siDeJJkUZJJVkp0=
|
||||
github.com/onsi/ginkgo/v2 v2.23.3/go.mod h1:zXTP6xIp3U8aVuXN8ENK9IXRaTjFnpVB9mGmaSRvxnM=
|
||||
github.com/onsi/gomega v1.37.0 h1:CdEG8g0S133B4OswTDC/5XPSzE1OeP29QOioj2PID2Y=
|
||||
github.com/onsi/gomega v1.37.0/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus/client_golang v1.19.1 h1:wZWJDwK+NameRJuPGDhlnFgx8e8HN3XHQeLaYJFJBOE=
|
||||
github.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho=
|
||||
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
|
||||
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
|
||||
github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc=
|
||||
github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8=
|
||||
github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q=
|
||||
github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0=
|
||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||
github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE=
|
||||
github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8=
|
||||
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
|
||||
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
|
||||
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
|
||||
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
|
||||
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
|
||||
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/soheilhy/cmux v0.1.5 h1:jjzc5WVemNEDTLwv9tlmemhC73tI08BNOIGwBOo10Js=
|
||||
github.com/soheilhy/cmux v0.1.5/go.mod h1:T7TcVDs9LWfQgPlPsdngu6I6QIoyIFZDDC6sNE1GqG0=
|
||||
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
|
||||
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
|
||||
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
|
||||
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
|
||||
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/pflag v1.0.7 h1:vN6T9TfwStFPFM5XzjsvmzZkLuaLX+HS+0SeFLRgU6M=
|
||||
github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/stoewer/go-strcase v1.3.0 h1:g0eASXYtp+yvN9fK8sH94oCIk0fau9uV1/ZdJ0AVEzs=
|
||||
github.com/stoewer/go-strcase v1.3.0/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8wodgtPmh1xo=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/tmc/grpc-websocket-proxy v0.0.0-20220101234140-673ab2c3ae75 h1:6fotK7otjonDflCTK0BCfls4SPy3NcCVb5dqqmbRknE=
|
||||
github.com/tmc/grpc-websocket-proxy v0.0.0-20220101234140-673ab2c3ae75/go.mod h1:KO6IkyS8Y3j8OdNO85qEYBsRPuteD+YciPomcXdrMnk=
|
||||
github.com/vmware-tanzu/velero v1.17.1 h1:ldKeiTuUwkThOw7zrUucNA1NwnLG66zl13YetWAoE0I=
|
||||
github.com/vmware-tanzu/velero v1.17.1/go.mod h1:3KTxuUN6Un38JzmYAX+8U6j2k6EexGoNNxa8jrJML8U=
|
||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||
github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2 h1:eY9dn8+vbi4tKz5Qo6v2eYzo7kUS51QINcR5jNpbZS8=
|
||||
github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU=
|
||||
github.com/xiang90/probing v0.0.0-20221125231312-a49e3df8f510 h1:S2dVYn90KE98chqDkyE9Z4N61UnQd+KOfgp5Iu53llk=
|
||||
github.com/xiang90/probing v0.0.0-20221125231312-a49e3df8f510/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU=
|
||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
go.etcd.io/bbolt v1.3.9 h1:8x7aARPEXiXbHmtUwAIv7eV2fQFHrLLavdiJ3uzJXoI=
|
||||
go.etcd.io/bbolt v1.3.9/go.mod h1:zaO32+Ti0PK1ivdPtgMESzuzL2VPoIG1PCQNvOdo/dE=
|
||||
go.etcd.io/etcd/api/v3 v3.5.16 h1:WvmyJVbjWqK4R1E+B12RRHz3bRGy9XVfh++MgbN+6n0=
|
||||
go.etcd.io/etcd/api/v3 v3.5.16/go.mod h1:1P4SlIP/VwkDmGo3OlOD7faPeP8KDIFhqvciH5EfN28=
|
||||
go.etcd.io/etcd/client/pkg/v3 v3.5.16 h1:ZgY48uH6UvB+/7R9Yf4x574uCO3jIx0TRDyetSfId3Q=
|
||||
go.etcd.io/etcd/client/pkg/v3 v3.5.16/go.mod h1:V8acl8pcEK0Y2g19YlOV9m9ssUe6MgiDSobSoaBAM0E=
|
||||
go.etcd.io/etcd/client/v2 v2.305.13 h1:RWfV1SX5jTU0lbCvpVQe3iPQeAHETWdOTb6pxhd77C8=
|
||||
go.etcd.io/etcd/client/v2 v2.305.13/go.mod h1:iQnL7fepbiomdXMb3om1rHq96htNNGv2sJkEcZGDRRg=
|
||||
go.etcd.io/etcd/client/v3 v3.5.16 h1:sSmVYOAHeC9doqi0gv7v86oY/BTld0SEFGaxsU9eRhE=
|
||||
go.etcd.io/etcd/client/v3 v3.5.16/go.mod h1:X+rExSGkyqxvu276cr2OwPLBaeqFu1cIl4vmRjAD/50=
|
||||
go.etcd.io/etcd/pkg/v3 v3.5.13 h1:st9bDWNsKkBNpP4PR1MvM/9NqUPfvYZx/YXegsYEH8M=
|
||||
go.etcd.io/etcd/pkg/v3 v3.5.13/go.mod h1:N+4PLrp7agI/Viy+dUYpX7iRtSPvKq+w8Y14d1vX+m0=
|
||||
go.etcd.io/etcd/raft/v3 v3.5.13 h1:7r/NKAOups1YnKcfro2RvGGo2PTuizF/xh26Z2CTAzA=
|
||||
go.etcd.io/etcd/raft/v3 v3.5.13/go.mod h1:uUFibGLn2Ksm2URMxN1fICGhk8Wu96EfDQyuLhAcAmw=
|
||||
go.etcd.io/etcd/server/v3 v3.5.13 h1:V6KG+yMfMSqWt+lGnhFpP5z5dRUj1BDRJ5k1fQ9DFok=
|
||||
go.etcd.io/etcd/server/v3 v3.5.13/go.mod h1:K/8nbsGupHqmr5MkgaZpLlH1QdX1pcNQLAkODy44XcQ=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.53.0 h1:9G6E0TXzGFVfTnawRzrPl83iHOAV7L8NJiR8RSGYV1g=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.53.0/go.mod h1:azvtTADFQJA8mX80jIH/akaE7h+dbm/sVuaHqN13w74=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.53.0 h1:4K4tsIXefpVJtvA/8srF4V4y0akAoPHkIslgAkjixJA=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.53.0/go.mod h1:jjdQuTGVsXV4vSs+CJ2qYDeDPf9yIJV23qlIzBm73Vg=
|
||||
go.opentelemetry.io/otel v1.28.0 h1:/SqNcYk+idO0CxKEUOtKQClMK/MimZihKYMruSMViUo=
|
||||
go.opentelemetry.io/otel v1.28.0/go.mod h1:q68ijF8Fc8CnMHKyzqL6akLO46ePnjkgfIMIjUIX9z4=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.28.0 h1:3Q/xZUyC1BBkualc9ROb4G8qkH90LXEIICcs5zv1OYY=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.28.0/go.mod h1:s75jGIWA9OfCMzF0xr+ZgfrB5FEbbV7UuYo32ahUiFI=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.27.0 h1:qFffATk0X+HD+f1Z8lswGiOQYKHRlzfmdJm0wEaVrFA=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.27.0/go.mod h1:MOiCmryaYtc+V0Ei+Tx9o5S1ZjA7kzLucuVuyzBZloQ=
|
||||
go.opentelemetry.io/otel/metric v1.28.0 h1:f0HGvSl1KRAU1DLgLGFjrwVyismPlnuU6JD6bOeuA5Q=
|
||||
go.opentelemetry.io/otel/metric v1.28.0/go.mod h1:Fb1eVBFZmLVTMb6PPohq3TO9IIhUisDsbJoL/+uQW4s=
|
||||
go.opentelemetry.io/otel/sdk v1.28.0 h1:b9d7hIry8yZsgtbmM0DKyPWMMUMlK9NEKuIG4aBqWyE=
|
||||
go.opentelemetry.io/otel/sdk v1.28.0/go.mod h1:oYj7ClPUA7Iw3m+r7GeEjz0qckQRJK2B8zjcZEfu7Pg=
|
||||
go.opentelemetry.io/otel/trace v1.28.0 h1:GhQ9cUuQGmNDd5BTCP2dAvv75RdMxEfTmYejp+lkx9g=
|
||||
go.opentelemetry.io/otel/trace v1.28.0/go.mod h1:jPyXzNPg6da9+38HEwElrQiHlVMTnVfM3/yv2OlIHaI=
|
||||
go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0=
|
||||
go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8=
|
||||
go.etcd.io/bbolt v1.4.2 h1:IrUHp260R8c+zYx/Tm8QZr04CX+qWS5PGfPdevhdm1I=
|
||||
go.etcd.io/bbolt v1.4.2/go.mod h1:Is8rSHO/b4f3XigBC0lL0+4FwAQv3HXEEIgFMuKHceM=
|
||||
go.etcd.io/etcd/api/v3 v3.6.4 h1:7F6N7toCKcV72QmoUKa23yYLiiljMrT4xCeBL9BmXdo=
|
||||
go.etcd.io/etcd/api/v3 v3.6.4/go.mod h1:eFhhvfR8Px1P6SEuLT600v+vrhdDTdcfMzmnxVXXSbk=
|
||||
go.etcd.io/etcd/client/pkg/v3 v3.6.4 h1:9HBYrjppeOfFjBjaMTRxT3R7xT0GLK8EJMVC4xg6ok0=
|
||||
go.etcd.io/etcd/client/pkg/v3 v3.6.4/go.mod h1:sbdzr2cl3HzVmxNw//PH7aLGVtY4QySjQFuaCgcRFAI=
|
||||
go.etcd.io/etcd/client/v3 v3.6.4 h1:YOMrCfMhRzY8NgtzUsHl8hC2EBSnuqbR3dh84Uryl7A=
|
||||
go.etcd.io/etcd/client/v3 v3.6.4/go.mod h1:jaNNHCyg2FdALyKWnd7hxZXZxZANb0+KGY+YQaEMISo=
|
||||
go.etcd.io/etcd/pkg/v3 v3.6.4 h1:fy8bmXIec1Q35/jRZ0KOes8vuFxbvdN0aAFqmEfJZWA=
|
||||
go.etcd.io/etcd/pkg/v3 v3.6.4/go.mod h1:kKcYWP8gHuBRcteyv6MXWSN0+bVMnfgqiHueIZnKMtE=
|
||||
go.etcd.io/etcd/server/v3 v3.6.4 h1:LsCA7CzjVt+8WGrdsnh6RhC0XqCsLkBly3ve5rTxMAU=
|
||||
go.etcd.io/etcd/server/v3 v3.6.4/go.mod h1:aYCL/h43yiONOv0QIR82kH/2xZ7m+IWYjzRmyQfnCAg=
|
||||
go.etcd.io/raft/v3 v3.6.0 h1:5NtvbDVYpnfZWcIHgGRk9DyzkBIXOi8j+DDp1IcnUWQ=
|
||||
go.etcd.io/raft/v3 v3.6.0/go.mod h1:nLvLevg6+xrVtHUmVaTcTz603gQPHfh7kUAwV6YpfGo=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 h1:q4XOmH/0opmeuJtPsbFNivyl7bCt7yRBbeEm2sC/XtQ=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0/go.mod h1:snMWehoOh2wsEwnvvwtDyFCxVeDAODenXHtn5vzrKjo=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
|
||||
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
|
||||
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0 h1:OeNbIYk/2C15ckl7glBlOBp5+WlYsOElzTNmiPW/x60=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0/go.mod h1:7Bept48yIeqxP2OZ9/AqIpYS94h2or0aB4FypJTc8ZM=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.34.0 h1:tgJ0uaNS4c98WRNUEx5U3aDlrDOI5Rs+1Vifcw4DJ8U=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.34.0/go.mod h1:U7HYyW0zt/a9x5J1Kjs+r1f/d4ZHnYFclhYY2+YbeoE=
|
||||
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
|
||||
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
|
||||
go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI=
|
||||
go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFwHX4dThiPis=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4=
|
||||
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
|
||||
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
|
||||
go.opentelemetry.io/proto/otlp v1.5.0 h1:xJvq7gMzB31/d406fB8U5CBdyQGw4P399D1aQWU/3i4=
|
||||
go.opentelemetry.io/proto/otlp v1.5.0/go.mod h1:keN8WnHxOy8PG0rQZjJJ5A2ebUoafqWp0eVQ4yIXvJ4=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
|
||||
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
||||
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
|
||||
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
|
||||
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
|
||||
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
|
||||
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
|
||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
@@ -228,50 +246,48 @@ golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
|
||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||
golang.org/x/oauth2 v0.23.0 h1:PbgcYx2W7i4LvjJWEbf0ngHV6qJYr86PkAV3bXdLEbs=
|
||||
golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
|
||||
golang.org/x/net v0.45.0 h1:RLBg5JKixCy82FtLJpeNlVM0nrSqpCRYzVU1n8kj0tM=
|
||||
golang.org/x/net v0.45.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
|
||||
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
|
||||
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q=
|
||||
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
|
||||
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ=
|
||||
golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/time v0.7.0 h1:ntUhktv3OPE6TgYxXWv9vKvUSJyIFJlyohwbkEwPrKQ=
|
||||
golang.org/x/time v0.7.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
||||
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
||||
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
|
||||
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.26.0 h1:v/60pFQmzmT9ExmjDv2gGIfi3OqfKoEP6I5+umXlbnQ=
|
||||
golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0=
|
||||
golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
|
||||
golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw=
|
||||
gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY=
|
||||
google.golang.org/genproto v0.0.0-20230822172742-b8732ec3820d h1:VBu5YqKPv6XiJ199exd8Br+Aetz+o08F+PLMnwJQHAY=
|
||||
google.golang.org/genproto v0.0.0-20230822172742-b8732ec3820d/go.mod h1:yZTlhN0tQnXo3h00fuXNCxJdLdIdnVFVBaRJ5LWBbw4=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20240528184218-531527333157 h1:7whR9kGa5LUwFtpLm2ArCEejtnxlGeLbAyjFY8sGNFw=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20240528184218-531527333157/go.mod h1:99sLkeliLXfdj2J75X3Ho+rrVCaJze0uwN7zDDkjPVU=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240701130421-f6361c86f094 h1:BwIjyKYGsK9dMCBOorzRri8MQwmi7mT9rGHsCEinZkA=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240701130421-f6361c86f094/go.mod h1:Ue6ibwXGpU+dqIcODieyLOcgj7z8+IcskoNIgZxtrFY=
|
||||
google.golang.org/grpc v1.65.0 h1:bs/cUb4lp1G5iImFFd3u5ixQzweKizoZJAwBNLR42lc=
|
||||
google.golang.org/grpc v1.65.0/go.mod h1:WgYC2ypjlB0EiQi6wdKixMqukr6lBc0Vo+oOgjrM5ZQ=
|
||||
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
|
||||
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 h1:oWVWY3NzT7KJppx2UKhKmzPq4SRe0LdCijVRwvGeikY=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822/go.mod h1:h3c4v36UTKzUiuaOKQ6gr3S+0hovBtUrXzTG/i3+XEc=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 h1:fc6jSaCT0vBduLYZHYrBBNY4dsWuvgyff9noRNDdBeE=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
|
||||
google.golang.org/grpc v1.73.0 h1:VIWSmpI2MegBtTuFt5/JWy2oXxtjJ/e89Z70ImfD2ok=
|
||||
google.golang.org/grpc v1.73.0/go.mod h1:50sbHOUqWoCQGI8V2HQLJM0B+LMlIUjNSZmow7EVBQc=
|
||||
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
||||
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
@@ -281,39 +297,42 @@ gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
|
||||
gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
|
||||
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
k8s.io/api v0.31.2 h1:3wLBbL5Uom/8Zy98GRPXpJ254nEFpl+hwndmk9RwmL0=
|
||||
k8s.io/api v0.31.2/go.mod h1:bWmGvrGPssSK1ljmLzd3pwCQ9MgoTsRCuK35u6SygUk=
|
||||
k8s.io/apiextensions-apiserver v0.31.2 h1:W8EwUb8+WXBLu56ser5IudT2cOho0gAKeTOnywBLxd0=
|
||||
k8s.io/apiextensions-apiserver v0.31.2/go.mod h1:i+Geh+nGCJEGiCGR3MlBDkS7koHIIKWVfWeRFiOsUcM=
|
||||
k8s.io/apimachinery v0.31.2 h1:i4vUt2hPK56W6mlT7Ry+AO8eEsyxMD1U44NR22CLTYw=
|
||||
k8s.io/apimachinery v0.31.2/go.mod h1:rsPdaZJfTfLsNJSQzNHQvYoTmxhoOEofxtOsF3rtsMo=
|
||||
k8s.io/apiserver v0.31.2 h1:VUzOEUGRCDi6kX1OyQ801m4A7AUPglpsmGvdsekmcI4=
|
||||
k8s.io/apiserver v0.31.2/go.mod h1:o3nKZR7lPlJqkU5I3Ove+Zx3JuoFjQobGX1Gctw6XuE=
|
||||
k8s.io/client-go v0.31.2 h1:Y2F4dxU5d3AQj+ybwSMqQnpZH9F30//1ObxOKlTI9yc=
|
||||
k8s.io/client-go v0.31.2/go.mod h1:NPa74jSVR/+eez2dFsEIHNa+3o09vtNaWwWwb1qSxSs=
|
||||
k8s.io/component-base v0.31.2 h1:Z1J1LIaC0AV+nzcPRFqfK09af6bZ4D1nAOpWsy9owlA=
|
||||
k8s.io/component-base v0.31.2/go.mod h1:9PeyyFN/drHjtJZMCTkSpQJS3U9OXORnHQqMLDz0sUQ=
|
||||
k8s.io/api v0.34.1 h1:jC+153630BMdlFukegoEL8E/yT7aLyQkIVuwhmwDgJM=
|
||||
k8s.io/api v0.34.1/go.mod h1:SB80FxFtXn5/gwzCoN6QCtPD7Vbu5w2n1S0J5gFfTYk=
|
||||
k8s.io/apiextensions-apiserver v0.34.1 h1:NNPBva8FNAPt1iSVwIE0FsdrVriRXMsaWFMqJbII2CI=
|
||||
k8s.io/apiextensions-apiserver v0.34.1/go.mod h1:hP9Rld3zF5Ay2Of3BeEpLAToP+l4s5UlxiHfqRaRcMc=
|
||||
k8s.io/apiserver v0.34.1 h1:U3JBGdgANK3dfFcyknWde1G6X1F4bg7PXuvlqt8lITA=
|
||||
k8s.io/apiserver v0.34.1/go.mod h1:eOOc9nrVqlBI1AFCvVzsob0OxtPZUCPiUJL45JOTBG0=
|
||||
k8s.io/client-go v0.34.1 h1:ZUPJKgXsnKwVwmKKdPfw4tB58+7/Ik3CrjOEhsiZ7mY=
|
||||
k8s.io/client-go v0.34.1/go.mod h1:kA8v0FP+tk6sZA0yKLRG67LWjqufAoSHA2xVGKw9Of8=
|
||||
k8s.io/component-base v0.34.1 h1:v7xFgG+ONhytZNFpIz5/kecwD+sUhVE6HU7qQUiRM4A=
|
||||
k8s.io/component-base v0.34.1/go.mod h1:mknCpLlTSKHzAQJJnnHVKqjxR7gBeHRv0rPXA7gdtQ0=
|
||||
k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=
|
||||
k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=
|
||||
k8s.io/kms v0.31.2 h1:pyx7l2qVOkClzFMIWMVF/FxsSkgd+OIGH7DecpbscJI=
|
||||
k8s.io/kms v0.31.2/go.mod h1:OZKwl1fan3n3N5FFxnW5C4V3ygrah/3YXeJWS3O6+94=
|
||||
k8s.io/kube-openapi v0.0.0-20240827152857-f7e401e7b4c2 h1:GKE9U8BH16uynoxQii0auTjmmmuZ3O0LFMN6S0lPPhI=
|
||||
k8s.io/kube-openapi v0.0.0-20240827152857-f7e401e7b4c2/go.mod h1:coRQXBK9NxO98XUv3ZD6AK3xzHCxV6+b7lrquKwaKzA=
|
||||
k8s.io/utils v0.0.0-20240711033017-18e509b52bc8 h1:pUdcCO1Lk/tbT5ztQWOBi5HBgbBP1J8+AsQnQCKsi8A=
|
||||
k8s.io/utils v0.0.0-20240711033017-18e509b52bc8/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
|
||||
sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.0 h1:CPT0ExVicCzcpeN4baWEV2ko2Z/AsiZgEdwgcfwLgMo=
|
||||
sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.0/go.mod h1:Ve9uj1L+deCXFrPOk1LpFXqTg7LCFzFso6PA48q/XZw=
|
||||
sigs.k8s.io/controller-runtime v0.19.0 h1:nWVM7aq+Il2ABxwiCizrVDSlmDcshi9llbaFbC0ji/Q=
|
||||
sigs.k8s.io/controller-runtime v0.19.0/go.mod h1:iRmWllt8IlaLjvTTDLhRBXIEtkCK6hwVBJJsYS9Ajf4=
|
||||
sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 h1:/Rv+M11QRah1itp8VhT6HoVx1Ray9eB4DBr+K+/sCJ8=
|
||||
sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3/go.mod h1:18nIHnGi6636UCz6m8i4DhaJ65T6EruyzmoQqI2BVDo=
|
||||
sigs.k8s.io/structured-merge-diff/v4 v4.4.1 h1:150L+0vs/8DA78h1u02ooW1/fFq/Lwr+sGiqlzvrtq4=
|
||||
sigs.k8s.io/structured-merge-diff/v4 v4.4.1/go.mod h1:N8hJocpFajUSSeSJ9bOZ77VzejKZaXsTtZo4/u7Io08=
|
||||
sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E=
|
||||
k8s.io/kms v0.34.1 h1:iCFOvewDPzWM9fMTfyIPO+4MeuZ0tcZbugxLNSHFG4w=
|
||||
k8s.io/kms v0.34.1/go.mod h1:s1CFkLG7w9eaTYvctOxosx88fl4spqmixnNpys0JAtM=
|
||||
k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b h1:MloQ9/bdJyIu9lb1PzujOPolHyvO06MXG5TUIj2mNAA=
|
||||
k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b/go.mod h1:UZ2yyWbFTpuhSbFhv24aGNOdoRdJZgsIObGBUaYVsts=
|
||||
k8s.io/utils v0.0.0-20250820121507-0af2bda4dd1d h1:wAhiDyZ4Tdtt7e46e9M5ZSAJ/MnPGPs+Ki1gHw4w1R0=
|
||||
k8s.io/utils v0.0.0-20250820121507-0af2bda4dd1d/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
|
||||
sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2 h1:jpcvIRr3GLoUoEKRkHKSmGjxb6lWwrBlJsXc+eUYQHM=
|
||||
sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2/go.mod h1:Ve9uj1L+deCXFrPOk1LpFXqTg7LCFzFso6PA48q/XZw=
|
||||
sigs.k8s.io/controller-runtime v0.22.4 h1:GEjV7KV3TY8e+tJ2LCTxUTanW4z/FmNB7l327UfMq9A=
|
||||
sigs.k8s.io/controller-runtime v0.22.4/go.mod h1:+QX1XUpTXN4mLoblf4tqr5CQcyHPAki2HLXqQMY6vh8=
|
||||
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg=
|
||||
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg=
|
||||
sigs.k8s.io/randfill v0.0.0-20250304075658-069ef1bbf016/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY=
|
||||
sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU=
|
||||
sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY=
|
||||
sigs.k8s.io/structured-merge-diff/v4 v4.7.0 h1:qPeWmscJcXP0snki5IYF79Z8xrl8ETFxgMd7wez1XkI=
|
||||
sigs.k8s.io/structured-merge-diff/v4 v4.7.0/go.mod h1:dDy58f92j70zLsuZVuUX5Wp9vtxXpaZnkPGWeqDfCps=
|
||||
sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco=
|
||||
sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE=
|
||||
sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY=
|
||||
sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs=
|
||||
sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=
|
||||
|
||||
@@ -48,7 +48,7 @@ echo " End: $RELEASE_END"
|
||||
echo ""
|
||||
|
||||
# Loop through ALL optional repositories
|
||||
for repo_name in talm boot-to-talos cozypkg cozy-proxy; do
|
||||
for repo_name in talm boot-to-talos cozyhr cozy-proxy; do
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo "Checking repository: $repo_name"
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
|
||||
226
hack/e2e-apps/backup.bats.disabled
Executable file
226
hack/e2e-apps/backup.bats.disabled
Executable file
@@ -0,0 +1,226 @@
|
||||
#!/usr/bin/env bats
|
||||
|
||||
# Test variables - stored for teardown
|
||||
TEST_NAMESPACE='tenant-test'
|
||||
TEST_BUCKET_NAME='test-backup-bucket'
|
||||
TEST_VM_NAME='test-backup-vm'
|
||||
TEST_BACKUPJOB_NAME='test-backup-job'
|
||||
|
||||
teardown() {
|
||||
# Clean up resources (runs even if test fails)
|
||||
namespace="${TEST_NAMESPACE}"
|
||||
bucket_name="${TEST_BUCKET_NAME}"
|
||||
vm_name="${TEST_VM_NAME}"
|
||||
backupjob_name="${TEST_BACKUPJOB_NAME}"
|
||||
|
||||
# Clean up port-forward if still running
|
||||
pkill -f "kubectl.*port-forward.*seaweedfs-s3" 2>/dev/null || true
|
||||
|
||||
# Clean up Velero resources in cozy-velero namespace
|
||||
# Find Velero backup by pattern matching namespace-backupjob
|
||||
for backup in $(kubectl -n cozy-velero get backups.velero.io -o jsonpath='{.items[*].metadata.name}' 2>/dev/null || true); do
|
||||
if echo "$backup" | grep -q "^${namespace}-${backupjob_name}-"; then
|
||||
kubectl -n cozy-velero delete backups.velero.io ${backup} --wait=false 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
|
||||
# Clean up BackupStorageLocation and VolumeSnapshotLocation (named: namespace-backupjob)
|
||||
BSL_NAME="${namespace}-${backupjob_name}"
|
||||
kubectl -n cozy-velero delete backupstoragelocations.velero.io ${BSL_NAME} --wait=false 2>/dev/null || true
|
||||
kubectl -n cozy-velero delete volumesnapshotlocations.velero.io ${BSL_NAME} --wait=false 2>/dev/null || true
|
||||
|
||||
# Clean up Velero credentials secret
|
||||
SECRET_NAME="backup-${namespace}-${backupjob_name}-s3-credentials"
|
||||
kubectl -n cozy-velero delete secret ${SECRET_NAME} --wait=false 2>/dev/null || true
|
||||
|
||||
# Clean up BackupJob
|
||||
kubectl -n ${namespace} delete backupjob ${backupjob_name} --wait=false 2>/dev/null || true
|
||||
|
||||
# Clean up Virtual Machine
|
||||
kubectl -n ${namespace} delete virtualmachines.apps.cozystack.io ${vm_name} --wait=false 2>/dev/null || true
|
||||
|
||||
# Clean up Bucket
|
||||
kubectl -n ${namespace} delete bucket.apps.cozystack.io ${bucket_name} --wait=false 2>/dev/null || true
|
||||
|
||||
# Clean up temporary files
|
||||
rm -f /tmp/bucket-backup-credentials.json
|
||||
}
|
||||
|
||||
print_log() {
|
||||
echo "# $1" >&3
|
||||
}
|
||||
|
||||
@test "Create Backup for Virtual Machine" {
|
||||
# Test variables
|
||||
bucket_name="${TEST_BUCKET_NAME}"
|
||||
vm_name="${TEST_VM_NAME}"
|
||||
backupjob_name="${TEST_BACKUPJOB_NAME}"
|
||||
namespace="${TEST_NAMESPACE}"
|
||||
|
||||
print_log "Step 0:Ensure BackupJob and Velero strategy CRDs are installed"
|
||||
kubectl apply -f packages/system/backup-controller/definitions/backups.cozystack.io_backupjobs.yaml
|
||||
kubectl apply -f packages/system/backupstrategy-controller/definitions/strategy.backups.cozystack.io_veleroes.yaml
|
||||
# Wait for CRDs to be ready
|
||||
kubectl wait --for condition=established --timeout=30s crd backupjobs.backups.cozystack.io
|
||||
kubectl wait --for condition=established --timeout=30s crd veleroes.strategy.backups.cozystack.io
|
||||
|
||||
# Ensure velero-strategy-default resource exists
|
||||
kubectl apply -f packages/system/backup-controller/templates/strategy.yaml
|
||||
|
||||
print_log "Step 1: Create the bucket resource"
|
||||
kubectl apply -f - <<EOF
|
||||
apiVersion: apps.cozystack.io/v1alpha1
|
||||
kind: Bucket
|
||||
metadata:
|
||||
name: ${bucket_name}
|
||||
namespace: ${namespace}
|
||||
spec: {}
|
||||
EOF
|
||||
|
||||
print_log "Wait for the bucket to be ready"
|
||||
kubectl -n ${namespace} wait hr bucket-${bucket_name} --timeout=100s --for=condition=ready
|
||||
kubectl -n ${namespace} wait bucketclaims.objectstorage.k8s.io bucket-${bucket_name} --timeout=300s --for=jsonpath='{.status.bucketReady}'=true
|
||||
kubectl -n ${namespace} wait bucketaccesses.objectstorage.k8s.io bucket-${bucket_name} --timeout=300s --for=jsonpath='{.status.accessGranted}'=true
|
||||
|
||||
# Get bucket credentials for later S3 verification
|
||||
kubectl -n ${namespace} get secret bucket-${bucket_name} -ojsonpath='{.data.BucketInfo}' | base64 -d > /tmp/bucket-backup-credentials.json
|
||||
ACCESS_KEY=$(jq -r '.spec.secretS3.accessKeyID' /tmp/bucket-backup-credentials.json)
|
||||
SECRET_KEY=$(jq -r '.spec.secretS3.accessSecretKey' /tmp/bucket-backup-credentials.json)
|
||||
BUCKET_NAME=$(jq -r '.spec.bucketName' /tmp/bucket-backup-credentials.json)
|
||||
|
||||
print_log "Step 2: Create the Virtual Machine"
|
||||
kubectl apply -f - <<EOF
|
||||
apiVersion: apps.cozystack.io/v1alpha1
|
||||
kind: VirtualMachine
|
||||
metadata:
|
||||
name: ${vm_name}
|
||||
namespace: ${namespace}
|
||||
spec:
|
||||
external: false
|
||||
externalMethod: PortList
|
||||
externalPorts:
|
||||
- 22
|
||||
instanceType: "u1.medium"
|
||||
instanceProfile: ubuntu
|
||||
systemDisk:
|
||||
image: ubuntu
|
||||
storage: 5Gi
|
||||
storageClass: replicated
|
||||
gpus: []
|
||||
resources: {}
|
||||
sshKeys:
|
||||
- ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPht0dPk5qQ+54g1hSX7A6AUxXJW5T6n/3d7Ga2F8gTF
|
||||
test@test
|
||||
cloudInit: |
|
||||
#cloud-config
|
||||
users:
|
||||
- name: test
|
||||
shell: /bin/bash
|
||||
sudo: ['ALL=(ALL) NOPASSWD: ALL']
|
||||
groups: sudo
|
||||
ssh_authorized_keys:
|
||||
- ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPht0dPk5qQ+54g1hSX7A6AUxXJW5T6n/3d7Ga2F8gTF test@test
|
||||
cloudInitSeed: ""
|
||||
EOF
|
||||
|
||||
print_log "Wait for VM to be ready"
|
||||
sleep 5
|
||||
kubectl -n ${namespace} wait hr virtual-machine-${vm_name} --timeout=10s --for=condition=ready
|
||||
kubectl -n ${namespace} wait dv virtual-machine-${vm_name} --timeout=150s --for=condition=ready
|
||||
kubectl -n ${namespace} wait pvc virtual-machine-${vm_name} --timeout=100s --for=jsonpath='{.status.phase}'=Bound
|
||||
kubectl -n ${namespace} wait vm virtual-machine-${vm_name} --timeout=100s --for=condition=ready
|
||||
|
||||
print_log "Step 3: Create BackupJob"
|
||||
kubectl apply -f - <<EOF
|
||||
apiVersion: backups.cozystack.io/v1alpha1
|
||||
kind: BackupJob
|
||||
metadata:
|
||||
name: ${backupjob_name}
|
||||
namespace: ${namespace}
|
||||
labels:
|
||||
backups.cozystack.io/triggered-by: e2e-test
|
||||
spec:
|
||||
applicationRef:
|
||||
apiGroup: apps.cozystack.io
|
||||
kind: VirtualMachine
|
||||
name: ${vm_name}
|
||||
storageRef:
|
||||
apiGroup: apps.cozystack.io
|
||||
kind: Bucket
|
||||
name: ${bucket_name}
|
||||
strategyRef:
|
||||
apiGroup: strategy.backups.cozystack.io
|
||||
kind: Velero
|
||||
name: velero-strategy-default
|
||||
EOF
|
||||
|
||||
print_log "Wait for BackupJob to start"
|
||||
kubectl -n ${namespace} wait backupjob ${backupjob_name} --timeout=60s --for=jsonpath='{.status.phase}'=Running
|
||||
|
||||
print_log "Wait for BackupJob to complete"
|
||||
kubectl -n ${namespace} wait backupjob ${backupjob_name} --timeout=300s --for=jsonpath='{.status.phase}'=Succeeded
|
||||
|
||||
print_log "Verify BackupJob status"
|
||||
PHASE=$(kubectl -n ${namespace} get backupjob ${backupjob_name} -o jsonpath='{.status.phase}')
|
||||
[ "$PHASE" = "Succeeded" ]
|
||||
|
||||
# Verify BackupJob has a backupRef
|
||||
BACKUP_REF=$(kubectl -n ${namespace} get backupjob ${backupjob_name} -o jsonpath='{.status.backupRef.name}')
|
||||
[ -n "$BACKUP_REF" ]
|
||||
|
||||
# Find the Velero backup by searching for backups matching the namespace-backupjob pattern
|
||||
# Format: namespace-backupjob-timestamp
|
||||
VELERO_BACKUP_NAME=""
|
||||
VELERO_BACKUP_PHASE=""
|
||||
|
||||
print_log "Wait a bit for the backup to be created and appear in the API"
|
||||
sleep 30
|
||||
|
||||
# Find backup by pattern matching namespace-backupjob
|
||||
for backup in $(kubectl -n cozy-velero get backups.velero.io -o jsonpath='{.items[*].metadata.name}' 2>/dev/null); do
|
||||
if echo "$backup" | grep -q "^${namespace}-${backupjob_name}-"; then
|
||||
VELERO_BACKUP_NAME=$backup
|
||||
VELERO_BACKUP_PHASE=$(kubectl -n cozy-velero get backups.velero.io $backup -o jsonpath='{.status.phase}' 2>/dev/null || echo "")
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
print_log "Verify Velero Backup was found"
|
||||
[ -n "$VELERO_BACKUP_NAME" ]
|
||||
|
||||
echo '# Wait for Velero Backup to complete' >&3
|
||||
until kubectl -n cozy-velero get backups.velero.io ${VELERO_BACKUP_NAME} -o jsonpath='{.status.phase}' | grep -q 'Completed\|Failed'; do
|
||||
sleep 5
|
||||
done
|
||||
|
||||
print_log "Verify Velero Backup is Completed"
|
||||
timeout 90 sh -ec "until [ \"\$(kubectl -n cozy-velero get backups.velero.io ${VELERO_BACKUP_NAME} -o jsonpath='{.status.phase}' 2>/dev/null)\" = \"Completed\" ]; do sleep 30; done"
|
||||
|
||||
# Final verification
|
||||
VELERO_BACKUP_PHASE=$(kubectl -n cozy-velero get backups.velero.io ${VELERO_BACKUP_NAME} -o jsonpath='{.status.phase}' 2>/dev/null || echo "")
|
||||
[ "$VELERO_BACKUP_PHASE" = "Completed" ]
|
||||
|
||||
print_log "Step 4: Verify S3 has backup data"
|
||||
# Start port-forwarding to S3 service (with timeout to keep it alive)
|
||||
bash -c 'timeout 100s kubectl port-forward service/seaweedfs-s3 -n tenant-root 8333:8333 > /dev/null 2>&1 &'
|
||||
|
||||
# Wait for port-forward to be ready
|
||||
timeout 30 sh -ec "until nc -z localhost 8333; do sleep 1; done"
|
||||
|
||||
# Wait a bit for backup data to be written to S3
|
||||
sleep 30
|
||||
|
||||
# Set up MinIO client with insecure flag (use environment variable for all commands)
|
||||
export MC_INSECURE=1
|
||||
mc alias set local https://localhost:8333 $ACCESS_KEY $SECRET_KEY
|
||||
|
||||
# Verify backup directory exists in S3
|
||||
BACKUP_PATH="${BUCKET_NAME}/backups/${VELERO_BACKUP_NAME}"
|
||||
mc ls local/${BACKUP_PATH}/ 2>/dev/null
|
||||
[ $? -eq 0 ]
|
||||
|
||||
# Verify backup files exist (at least metadata files)
|
||||
BACKUP_FILES=$(mc ls local/${BACKUP_PATH}/ 2>/dev/null | wc -l || echo "0")
|
||||
[ "$BACKUP_FILES" -gt "0" ]
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ EOF
|
||||
kubectl wait --for=condition=TenantControlPlaneCreated kamajicontrolplane -n tenant-test kubernetes-${test_name} --timeout=4m
|
||||
|
||||
# Wait for Kubernetes resources to be ready (timeout after 2 minutes)
|
||||
kubectl wait tcp -n tenant-test kubernetes-${test_name} --timeout=2m --for=jsonpath='{.status.kubernetesResources.version.status}'=Ready
|
||||
kubectl wait tcp -n tenant-test kubernetes-${test_name} --timeout=5m --for=jsonpath='{.status.kubernetesResources.version.status}'=Ready
|
||||
|
||||
# Wait for all required deployments to be available (timeout after 4 minutes)
|
||||
kubectl wait deploy --timeout=4m --for=condition=available -n tenant-test kubernetes-${test_name} kubernetes-${test_name}-cluster-autoscaler kubernetes-${test_name}-kccm kubernetes-${test_name}-kcsi-controller
|
||||
@@ -87,7 +87,7 @@ EOF
|
||||
|
||||
|
||||
# Set up port forwarding to the Kubernetes API server for a 200 second timeout
|
||||
bash -c 'timeout 300s kubectl port-forward service/kubernetes-'"${test_name}"' -n tenant-test '"${port}"':6443 > /dev/null 2>&1 &'
|
||||
bash -c 'timeout 500s kubectl port-forward service/kubernetes-'"${test_name}"' -n tenant-test '"${port}"':6443 > /dev/null 2>&1 &'
|
||||
# Verify the Kubernetes version matches what we expect (retry for up to 20 seconds)
|
||||
timeout 20 sh -ec 'until kubectl --kubeconfig tenantkubeconfig-'"${test_name}"' version 2>/dev/null | grep -Fq "Server Version: ${k8s_version}"; do sleep 5; done'
|
||||
|
||||
@@ -124,6 +124,100 @@ EOF
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
kubectl --kubeconfig tenantkubeconfig-${test_name} apply -f - <<EOF
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: tenant-test
|
||||
EOF
|
||||
|
||||
# Backend 1
|
||||
kubectl apply --kubeconfig tenantkubeconfig-${test_name} -f- <<EOF
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: "${test_name}-backend"
|
||||
namespace: tenant-test
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: backend
|
||||
backend: "${test_name}-backend"
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: backend
|
||||
backend: "${test_name}-backend"
|
||||
spec:
|
||||
containers:
|
||||
- name: nginx
|
||||
image: nginx:alpine
|
||||
ports:
|
||||
- containerPort: 80
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
port: 80
|
||||
initialDelaySeconds: 2
|
||||
periodSeconds: 2
|
||||
EOF
|
||||
|
||||
# LoadBalancer Service
|
||||
kubectl apply --kubeconfig tenantkubeconfig-${test_name} -f- <<EOF
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: "${test_name}-backend"
|
||||
namespace: tenant-test
|
||||
spec:
|
||||
type: LoadBalancer
|
||||
selector:
|
||||
app: backend
|
||||
backend: "${test_name}-backend"
|
||||
ports:
|
||||
- port: 80
|
||||
targetPort: 80
|
||||
EOF
|
||||
|
||||
# Wait for pods readiness
|
||||
kubectl wait deployment --kubeconfig tenantkubeconfig-${test_name} ${test_name}-backend -n tenant-test --for=condition=Available --timeout=90s
|
||||
|
||||
# Wait for LoadBalancer to be provisioned (IP or hostname)
|
||||
timeout 90 sh -ec "
|
||||
until kubectl get svc ${test_name}-backend --kubeconfig tenantkubeconfig-${test_name} -n tenant-test \
|
||||
-o jsonpath='{.status.loadBalancer.ingress[0]}' | grep -q .; do
|
||||
sleep 5
|
||||
done
|
||||
"
|
||||
|
||||
LB_ADDR=$(
|
||||
kubectl get svc --kubeconfig tenantkubeconfig-${test_name} "${test_name}-backend" \
|
||||
-n tenant-test \
|
||||
-o jsonpath='{.status.loadBalancer.ingress[0].ip}{.status.loadBalancer.ingress[0].hostname}'
|
||||
)
|
||||
|
||||
if [ -z "$LB_ADDR" ]; then
|
||||
echo "LoadBalancer address is empty" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for i in $(seq 1 20); do
|
||||
echo "Attempt $i"
|
||||
curl --silent --fail "http://${LB_ADDR}" && break
|
||||
sleep 3
|
||||
done
|
||||
|
||||
if [ "$i" -eq 20 ]; then
|
||||
echo "LoadBalancer not reachable" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Cleanup
|
||||
kubectl delete deployment --kubeconfig tenantkubeconfig-${test_name} "${test_name}-backend" -n tenant-test
|
||||
kubectl delete service --kubeconfig tenantkubeconfig-${test_name} "${test_name}-backend" -n tenant-test
|
||||
|
||||
# Wait for all machine deployment replicas to be ready (timeout after 10 minutes)
|
||||
kubectl wait machinedeployment kubernetes-${test_name}-md0 -n tenant-test --timeout=10m --for=jsonpath='{.status.v1beta2.readyReplicas}'=2
|
||||
|
||||
|
||||
@@ -21,14 +21,33 @@
|
||||
}
|
||||
|
||||
@test "Test kinds" {
|
||||
val=$(kubectl get --raw /apis/apps.cozystack.io/v1alpha1/tenants | jq -r '.kind')
|
||||
if [ "$val" != "TenantList" ]; then
|
||||
echo "Expected kind to be TenantList, got $val"
|
||||
exit 1
|
||||
fi
|
||||
val=$(kubectl get --raw /apis/apps.cozystack.io/v1alpha1/tenants | jq -r '.items[0].kind')
|
||||
if [ "$val" != "Tenant" ]; then
|
||||
echo "Expected kind to be Tenant, got $val"
|
||||
exit 1
|
||||
fi
|
||||
val=$(kubectl get --raw /apis/apps.cozystack.io/v1alpha1/ingresses | jq -r '.kind')
|
||||
if [ "$val" != "IngressList" ]; then
|
||||
echo "Expected kind to be IngressList, got $val"
|
||||
exit 1
|
||||
fi
|
||||
val=$(kubectl get --raw /apis/apps.cozystack.io/v1alpha1/ingresses | jq -r '.items[0].kind')
|
||||
if [ "$val" != "Ingress" ]; then
|
||||
echo "Expected kind to be Ingress, got $val"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
@test "Create and delete namespace" {
|
||||
kubectl create ns cozy-test-create-and-delete-namespace --dry-run=client -o yaml | kubectl apply -f -
|
||||
if ! kubectl delete ns cozy-test-create-and-delete-namespace; then
|
||||
echo "Failed to delete namespace"
|
||||
kubectl describe ns cozy-test-create-and-delete-namespace
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -23,6 +23,14 @@ CODEGEN_PKG=${CODEGEN_PKG:-$(cd "${SCRIPT_ROOT}"; ls -d -1 ./vendor/k8s.io/code-
|
||||
API_KNOWN_VIOLATIONS_DIR="${API_KNOWN_VIOLATIONS_DIR:-"${SCRIPT_ROOT}/api/api-rules"}"
|
||||
UPDATE_API_KNOWN_VIOLATIONS="${UPDATE_API_KNOWN_VIOLATIONS:-true}"
|
||||
CONTROLLER_GEN="go run sigs.k8s.io/controller-tools/cmd/controller-gen@v0.16.4"
|
||||
TMPDIR=$(mktemp -d)
|
||||
OPERATOR_CRDDIR=packages/core/installer/definitions
|
||||
COZY_CONTROLLER_CRDDIR=packages/system/cozystack-controller/definitions
|
||||
COZY_RD_CRDDIR=packages/system/cozystack-resource-definition-crd/definition
|
||||
BACKUPS_CORE_CRDDIR=packages/system/backup-controller/definitions
|
||||
BACKUPSTRATEGY_CRDDIR=packages/system/backupstrategy-controller/definitions
|
||||
|
||||
trap 'rm -rf ${TMPDIR}' EXIT
|
||||
|
||||
source "${CODEGEN_PKG}/kube_codegen.sh"
|
||||
|
||||
@@ -53,6 +61,15 @@ kube::codegen::gen_openapi \
|
||||
"${SCRIPT_ROOT}/pkg/apis"
|
||||
|
||||
$CONTROLLER_GEN object:headerFile="hack/boilerplate.go.txt" paths="./api/..."
|
||||
$CONTROLLER_GEN rbac:roleName=manager-role crd paths="./api/..." output:crd:artifacts:config=packages/system/cozystack-controller/crds
|
||||
mv packages/system/cozystack-controller/crds/cozystack.io_cozystackresourcedefinitions.yaml \
|
||||
packages/system/cozystack-resource-definition-crd/definition/cozystack.io_cozystackresourcedefinitions.yaml
|
||||
$CONTROLLER_GEN rbac:roleName=manager-role crd paths="./api/..." output:crd:artifacts:config=${TMPDIR}
|
||||
|
||||
mv ${TMPDIR}/cozystack.io_packages.yaml ${OPERATOR_CRDDIR}/cozystack.io_packages.yaml
|
||||
mv ${TMPDIR}/cozystack.io_packagesources.yaml ${OPERATOR_CRDDIR}/cozystack.io_packagesources.yaml
|
||||
|
||||
mv ${TMPDIR}/cozystack.io_cozystackresourcedefinitions.yaml \
|
||||
${COZY_RD_CRDDIR}/cozystack.io_cozystackresourcedefinitions.yaml
|
||||
|
||||
mv ${TMPDIR}/backups.cozystack.io*.yaml ${BACKUPS_CORE_CRDDIR}/
|
||||
mv ${TMPDIR}/strategy.backups.cozystack.io*.yaml ${BACKUPSTRATEGY_CRDDIR}/
|
||||
|
||||
mv ${TMPDIR}/*.yaml ${COZY_CONTROLLER_CRDDIR}/
|
||||
|
||||
@@ -8,7 +8,6 @@ need yq; need jq; need base64
|
||||
CHART_YAML="${CHART_YAML:-Chart.yaml}"
|
||||
VALUES_YAML="${VALUES_YAML:-values.yaml}"
|
||||
SCHEMA_JSON="${SCHEMA_JSON:-values.schema.json}"
|
||||
CRD_DIR="../../system/cozystack-resource-definitions/cozyrds"
|
||||
|
||||
[[ -f "$CHART_YAML" ]] || { echo "No $CHART_YAML found"; exit 1; }
|
||||
[[ -f "$SCHEMA_JSON" ]] || { echo "No $SCHEMA_JSON found"; exit 1; }
|
||||
@@ -22,6 +21,8 @@ if [[ -z "$NAME" ]]; then
|
||||
echo "Chart.yaml: .name is empty"; exit 1
|
||||
fi
|
||||
|
||||
CRD_DIR="../../system/${NAME}-rd/cozyrds"
|
||||
|
||||
# Resolve icon path
|
||||
# Accepts:
|
||||
# /logos/foo.svg -> ./logos/foo.svg
|
||||
|
||||
93
internal/backupcontroller/backupjob_controller.go
Normal file
93
internal/backupcontroller/backupjob_controller.go
Normal file
@@ -0,0 +1,93 @@
|
||||
package backupcontroller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
"k8s.io/apimachinery/pkg/api/meta"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
"k8s.io/client-go/dynamic"
|
||||
"k8s.io/client-go/rest"
|
||||
"k8s.io/client-go/tools/record"
|
||||
ctrl "sigs.k8s.io/controller-runtime"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client/apiutil"
|
||||
"sigs.k8s.io/controller-runtime/pkg/log"
|
||||
|
||||
strategyv1alpha1 "github.com/cozystack/cozystack/api/backups/strategy/v1alpha1"
|
||||
backupsv1alpha1 "github.com/cozystack/cozystack/api/backups/v1alpha1"
|
||||
)
|
||||
|
||||
// BackupVeleroStrategyReconciler reconciles BackupJob with a strategy referencing
|
||||
// Velero.strategy.backups.cozystack.io objects.
|
||||
type BackupJobReconciler struct {
|
||||
client.Client
|
||||
dynamic.Interface
|
||||
meta.RESTMapper
|
||||
Scheme *runtime.Scheme
|
||||
Recorder record.EventRecorder
|
||||
}
|
||||
|
||||
func (r *BackupJobReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
|
||||
logger := log.FromContext(ctx)
|
||||
logger.Info("reconciling BackupJob", "namespace", req.Namespace, "name", req.Name)
|
||||
|
||||
j := &backupsv1alpha1.BackupJob{}
|
||||
err := r.Get(ctx, types.NamespacedName{Namespace: req.Namespace, Name: req.Name}, j)
|
||||
if err != nil {
|
||||
if apierrors.IsNotFound(err) {
|
||||
logger.V(1).Info("BackupJob not found, skipping")
|
||||
return ctrl.Result{}, nil
|
||||
}
|
||||
logger.Error(err, "failed to get BackupJob")
|
||||
return ctrl.Result{}, err
|
||||
}
|
||||
|
||||
if j.Spec.StrategyRef.APIGroup == nil {
|
||||
logger.V(1).Info("BackupJob has nil StrategyRef.APIGroup, skipping", "backupjob", j.Name)
|
||||
return ctrl.Result{}, nil
|
||||
}
|
||||
|
||||
if *j.Spec.StrategyRef.APIGroup != strategyv1alpha1.GroupVersion.Group {
|
||||
logger.V(1).Info("BackupJob StrategyRef.APIGroup doesn't match, skipping",
|
||||
"backupjob", j.Name,
|
||||
"expected", strategyv1alpha1.GroupVersion.Group,
|
||||
"got", *j.Spec.StrategyRef.APIGroup)
|
||||
return ctrl.Result{}, nil
|
||||
}
|
||||
|
||||
logger.Info("processing BackupJob", "backupjob", j.Name, "strategyKind", j.Spec.StrategyRef.Kind)
|
||||
switch j.Spec.StrategyRef.Kind {
|
||||
case strategyv1alpha1.JobStrategyKind:
|
||||
return r.reconcileJob(ctx, j)
|
||||
case strategyv1alpha1.VeleroStrategyKind:
|
||||
return r.reconcileVelero(ctx, j)
|
||||
default:
|
||||
logger.V(1).Info("BackupJob StrategyRef.Kind not supported, skipping",
|
||||
"backupjob", j.Name,
|
||||
"kind", j.Spec.StrategyRef.Kind,
|
||||
"supported", []string{strategyv1alpha1.JobStrategyKind, strategyv1alpha1.VeleroStrategyKind})
|
||||
return ctrl.Result{}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// SetupWithManager registers our controller with the Manager and sets up watches.
|
||||
func (r *BackupJobReconciler) SetupWithManager(mgr ctrl.Manager) error {
|
||||
cfg := mgr.GetConfig()
|
||||
var err error
|
||||
if r.Interface, err = dynamic.NewForConfig(cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
var h *http.Client
|
||||
if h, err = rest.HTTPClientFor(cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
if r.RESTMapper, err = apiutil.NewDynamicRESTMapper(cfg, h); err != nil {
|
||||
return err
|
||||
}
|
||||
return ctrl.NewControllerManagedBy(mgr).
|
||||
For(&backupsv1alpha1.BackupJob{}).
|
||||
Complete(r)
|
||||
}
|
||||
28
internal/backupcontroller/factory/backupjob.go
Normal file
28
internal/backupcontroller/factory/backupjob.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package factory
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
backupsv1alpha1 "github.com/cozystack/cozystack/api/backups/v1alpha1"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
)
|
||||
|
||||
func BackupJob(p *backupsv1alpha1.Plan, scheduledFor time.Time) *backupsv1alpha1.BackupJob {
|
||||
job := &backupsv1alpha1.BackupJob{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: fmt.Sprintf("%s-%d", p.Name, scheduledFor.Unix()/60),
|
||||
Namespace: p.Namespace,
|
||||
},
|
||||
Spec: backupsv1alpha1.BackupJobSpec{
|
||||
PlanRef: &corev1.LocalObjectReference{
|
||||
Name: p.Name,
|
||||
},
|
||||
ApplicationRef: *p.Spec.ApplicationRef.DeepCopy(),
|
||||
StorageRef: *p.Spec.StorageRef.DeepCopy(),
|
||||
StrategyRef: *p.Spec.StrategyRef.DeepCopy(),
|
||||
},
|
||||
}
|
||||
return job
|
||||
}
|
||||
15
internal/backupcontroller/jobstrategy_controller.go
Normal file
15
internal/backupcontroller/jobstrategy_controller.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package backupcontroller
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
ctrl "sigs.k8s.io/controller-runtime"
|
||||
"sigs.k8s.io/controller-runtime/pkg/log"
|
||||
|
||||
backupsv1alpha1 "github.com/cozystack/cozystack/api/backups/v1alpha1"
|
||||
)
|
||||
|
||||
func (r *BackupJobReconciler) reconcileJob(ctx context.Context, j *backupsv1alpha1.BackupJob) (ctrl.Result, error) {
|
||||
_ = log.FromContext(ctx)
|
||||
return ctrl.Result{}, nil
|
||||
}
|
||||
104
internal/backupcontroller/plan_controller.go
Normal file
104
internal/backupcontroller/plan_controller.go
Normal file
@@ -0,0 +1,104 @@
|
||||
package backupcontroller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
cron "github.com/robfig/cron/v3"
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
"k8s.io/apimachinery/pkg/api/meta"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
ctrl "sigs.k8s.io/controller-runtime"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
|
||||
"sigs.k8s.io/controller-runtime/pkg/log"
|
||||
|
||||
backupsv1alpha1 "github.com/cozystack/cozystack/api/backups/v1alpha1"
|
||||
"github.com/cozystack/cozystack/internal/backupcontroller/factory"
|
||||
)
|
||||
|
||||
const (
|
||||
minRequeueDelay = 30 * time.Second
|
||||
startingDeadlineSeconds = 300 * time.Second
|
||||
)
|
||||
|
||||
// PlanReconciler reconciles a Plan object
|
||||
type PlanReconciler struct {
|
||||
client.Client
|
||||
Scheme *runtime.Scheme
|
||||
}
|
||||
|
||||
func (r *PlanReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
|
||||
log := log.FromContext(ctx)
|
||||
|
||||
log.V(2).Info("reconciling")
|
||||
|
||||
p := &backupsv1alpha1.Plan{}
|
||||
|
||||
if err := r.Get(ctx, client.ObjectKey{Namespace: req.Namespace, Name: req.Name}, p); err != nil {
|
||||
if apierrors.IsNotFound(err) {
|
||||
log.V(3).Info("Plan not found")
|
||||
return ctrl.Result{}, nil
|
||||
}
|
||||
return ctrl.Result{}, err
|
||||
}
|
||||
|
||||
tCheck := time.Now().Add(-startingDeadlineSeconds)
|
||||
sch, err := cron.ParseStandard(p.Spec.Schedule.Cron)
|
||||
if err != nil {
|
||||
errWrapped := fmt.Errorf("could not parse cron %s: %w", p.Spec.Schedule.Cron, err)
|
||||
log.Error(err, "could not parse cron", "cron", p.Spec.Schedule.Cron)
|
||||
meta.SetStatusCondition(&p.Status.Conditions, metav1.Condition{
|
||||
Type: backupsv1alpha1.PlanConditionError,
|
||||
Status: metav1.ConditionTrue,
|
||||
Reason: "Failed to parse cron spec",
|
||||
Message: errWrapped.Error(),
|
||||
})
|
||||
if err := r.Status().Update(ctx, p); err != nil {
|
||||
return ctrl.Result{}, err
|
||||
}
|
||||
return ctrl.Result{}, nil
|
||||
}
|
||||
|
||||
// Clear error condition if cron parsing succeeds
|
||||
if condition := meta.FindStatusCondition(p.Status.Conditions, backupsv1alpha1.PlanConditionError); condition != nil && condition.Status == metav1.ConditionTrue {
|
||||
meta.SetStatusCondition(&p.Status.Conditions, metav1.Condition{
|
||||
Type: backupsv1alpha1.PlanConditionError,
|
||||
Status: metav1.ConditionFalse,
|
||||
Reason: "Cron spec is valid",
|
||||
Message: "The cron schedule has been successfully parsed",
|
||||
})
|
||||
if err := r.Status().Update(ctx, p); err != nil {
|
||||
return ctrl.Result{}, err
|
||||
}
|
||||
}
|
||||
|
||||
tNext := sch.Next(tCheck)
|
||||
|
||||
if time.Now().Before(tNext) {
|
||||
return ctrl.Result{RequeueAfter: tNext.Sub(time.Now())}, nil
|
||||
}
|
||||
|
||||
job := factory.BackupJob(p, tNext)
|
||||
if err := controllerutil.SetControllerReference(p, job, r.Scheme); err != nil {
|
||||
return ctrl.Result{}, err
|
||||
}
|
||||
|
||||
if err := r.Create(ctx, job); err != nil {
|
||||
if apierrors.IsAlreadyExists(err) {
|
||||
return ctrl.Result{RequeueAfter: startingDeadlineSeconds}, nil
|
||||
}
|
||||
return ctrl.Result{}, err
|
||||
}
|
||||
|
||||
return ctrl.Result{RequeueAfter: startingDeadlineSeconds}, nil
|
||||
}
|
||||
|
||||
// SetupWithManager registers our controller with the Manager and sets up watches.
|
||||
func (r *PlanReconciler) SetupWithManager(mgr ctrl.Manager) error {
|
||||
return ctrl.NewControllerManagedBy(mgr).
|
||||
For(&backupsv1alpha1.Plan{}).
|
||||
Complete(r)
|
||||
}
|
||||
627
internal/backupcontroller/velerostrategy_controller.go
Normal file
627
internal/backupcontroller/velerostrategy_controller.go
Normal file
@@ -0,0 +1,627 @@
|
||||
package backupcontroller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"time"
|
||||
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
"k8s.io/apimachinery/pkg/api/errors"
|
||||
"k8s.io/apimachinery/pkg/api/meta"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
ctrl "sigs.k8s.io/controller-runtime"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
"sigs.k8s.io/controller-runtime/pkg/log"
|
||||
|
||||
strategyv1alpha1 "github.com/cozystack/cozystack/api/backups/strategy/v1alpha1"
|
||||
backupsv1alpha1 "github.com/cozystack/cozystack/api/backups/v1alpha1"
|
||||
"github.com/cozystack/cozystack/internal/template"
|
||||
|
||||
"github.com/go-logr/logr"
|
||||
velerov1 "github.com/vmware-tanzu/velero/pkg/apis/velero/v1"
|
||||
)
|
||||
|
||||
func getLogger(ctx context.Context) loggerWithDebug {
|
||||
return loggerWithDebug{Logger: log.FromContext(ctx)}
|
||||
}
|
||||
|
||||
// loggerWithDebug wraps a logr.Logger and provides a Debug() method
|
||||
// that maps to V(1).Info() for convenience.
|
||||
type loggerWithDebug struct {
|
||||
logr.Logger
|
||||
}
|
||||
|
||||
// Debug logs at debug level (equivalent to V(1).Info())
|
||||
func (l loggerWithDebug) Debug(msg string, keysAndValues ...interface{}) {
|
||||
l.Logger.V(1).Info(msg, keysAndValues...)
|
||||
}
|
||||
|
||||
// S3Credentials holds the discovered S3 credentials from a Bucket storageRef
|
||||
type S3Credentials struct {
|
||||
BucketName string
|
||||
Endpoint string
|
||||
Region string
|
||||
AccessKeyID string
|
||||
AccessSecretKey string
|
||||
}
|
||||
|
||||
// bucketInfo represents the structure of BucketInfo stored in the secret
|
||||
type bucketInfo struct {
|
||||
Spec struct {
|
||||
BucketName string `json:"bucketName"`
|
||||
SecretS3 struct {
|
||||
Endpoint string `json:"endpoint"`
|
||||
Region string `json:"region"`
|
||||
AccessKeyID string `json:"accessKeyID"`
|
||||
AccessSecretKey string `json:"accessSecretKey"`
|
||||
} `json:"secretS3"`
|
||||
} `json:"spec"`
|
||||
}
|
||||
|
||||
const (
|
||||
defaultRequeueAfter = 5 * time.Second
|
||||
defaultActiveJobPollingInterval = defaultRequeueAfter
|
||||
// Velero requires API objects and secrets to be in the cozy-velero namespace
|
||||
veleroNamespace = "cozy-velero"
|
||||
virtualMachinePrefix = "virtual-machine-"
|
||||
)
|
||||
|
||||
func storageS3SecretName(namespace, backupJobName string) string {
|
||||
return fmt.Sprintf("backup-%s-%s-s3-credentials", namespace, backupJobName)
|
||||
}
|
||||
|
||||
func boolPtr(b bool) *bool {
|
||||
return &b
|
||||
}
|
||||
|
||||
func (r *BackupJobReconciler) reconcileVelero(ctx context.Context, j *backupsv1alpha1.BackupJob) (ctrl.Result, error) {
|
||||
logger := getLogger(ctx)
|
||||
logger.Debug("reconciling Velero strategy", "backupjob", j.Name, "phase", j.Status.Phase)
|
||||
|
||||
// If already completed, no need to reconcile
|
||||
if j.Status.Phase == backupsv1alpha1.BackupJobPhaseSucceeded ||
|
||||
j.Status.Phase == backupsv1alpha1.BackupJobPhaseFailed {
|
||||
logger.Debug("BackupJob already completed, skipping", "phase", j.Status.Phase)
|
||||
return ctrl.Result{}, nil
|
||||
}
|
||||
|
||||
// Step 1: On first reconcile, set startedAt (but not phase yet - phase will be set after backup creation)
|
||||
logger.Debug("checking BackupJob status", "startedAt", j.Status.StartedAt, "phase", j.Status.Phase)
|
||||
if j.Status.StartedAt == nil {
|
||||
logger.Debug("setting BackupJob StartedAt")
|
||||
now := metav1.Now()
|
||||
j.Status.StartedAt = &now
|
||||
// Don't set phase to Running yet - will be set after Velero backup is successfully created
|
||||
if err := r.Status().Update(ctx, j); err != nil {
|
||||
logger.Error(err, "failed to update BackupJob status")
|
||||
return ctrl.Result{}, err
|
||||
}
|
||||
logger.Debug("set BackupJob StartedAt", "startedAt", j.Status.StartedAt)
|
||||
} else {
|
||||
logger.Debug("BackupJob already started", "startedAt", j.Status.StartedAt, "phase", j.Status.Phase)
|
||||
}
|
||||
|
||||
// Step 2: Resolve inputs - Read Strategy, Storage, Application, optionally Plan
|
||||
logger.Debug("fetching Velero strategy", "strategyName", j.Spec.StrategyRef.Name)
|
||||
veleroStrategy := &strategyv1alpha1.Velero{}
|
||||
if err := r.Get(ctx, client.ObjectKey{Name: j.Spec.StrategyRef.Name}, veleroStrategy); err != nil {
|
||||
if errors.IsNotFound(err) {
|
||||
logger.Error(err, "Velero strategy not found", "strategyName", j.Spec.StrategyRef.Name)
|
||||
return r.markBackupJobFailed(ctx, j, fmt.Sprintf("Velero strategy not found: %s", j.Spec.StrategyRef.Name))
|
||||
}
|
||||
logger.Error(err, "failed to get Velero strategy")
|
||||
return ctrl.Result{}, err
|
||||
}
|
||||
logger.Debug("fetched Velero strategy", "strategyName", veleroStrategy.Name)
|
||||
|
||||
// Step 3: Execute backup logic
|
||||
// Check if we already created a Velero Backup
|
||||
// Use human-readable timestamp: YYYY-MM-DD-HH-MM-SS
|
||||
if j.Status.StartedAt == nil {
|
||||
logger.Error(nil, "StartedAt is nil after status update, this should not happen")
|
||||
return ctrl.Result{RequeueAfter: defaultRequeueAfter}, nil
|
||||
}
|
||||
logger.Debug("checking for existing Velero Backup", "namespace", veleroNamespace)
|
||||
veleroBackupList := &velerov1.BackupList{}
|
||||
opts := []client.ListOption{
|
||||
client.InNamespace(veleroNamespace),
|
||||
client.MatchingLabels{
|
||||
backupsv1alpha1.OwningJobNamespaceLabel: j.Namespace,
|
||||
backupsv1alpha1.OwningJobNameLabel: j.Name,
|
||||
},
|
||||
}
|
||||
|
||||
if err := r.List(ctx, veleroBackupList, opts...); err != nil {
|
||||
logger.Error(err, "failed to get Velero Backup")
|
||||
return ctrl.Result{}, err
|
||||
}
|
||||
|
||||
if len(veleroBackupList.Items) == 0 {
|
||||
// Create Velero Backup
|
||||
logger.Debug("Velero Backup not found, creating new one")
|
||||
if err := r.createVeleroBackup(ctx, j, veleroStrategy); err != nil {
|
||||
logger.Error(err, "failed to create Velero Backup")
|
||||
return r.markBackupJobFailed(ctx, j, fmt.Sprintf("failed to create Velero Backup: %v", err))
|
||||
}
|
||||
// After successful Velero backup creation, set phase to Running
|
||||
if j.Status.Phase != backupsv1alpha1.BackupJobPhaseRunning {
|
||||
logger.Debug("setting BackupJob phase to Running after successful Velero backup creation")
|
||||
j.Status.Phase = backupsv1alpha1.BackupJobPhaseRunning
|
||||
if err := r.Status().Update(ctx, j); err != nil {
|
||||
logger.Error(err, "failed to update BackupJob phase to Running")
|
||||
return ctrl.Result{}, err
|
||||
}
|
||||
}
|
||||
logger.Debug("created Velero Backup, requeuing")
|
||||
// Requeue to check status
|
||||
return ctrl.Result{RequeueAfter: defaultRequeueAfter}, nil
|
||||
}
|
||||
|
||||
if len(veleroBackupList.Items) > 1 {
|
||||
logger.Error(fmt.Errorf("too many Velero backups for BackupJob"), "found more than one Velero Backup referencing a single BackupJob as owner")
|
||||
j.Status.Phase = backupsv1alpha1.BackupJobPhaseFailed
|
||||
if err := r.Status().Update(ctx, j); err != nil {
|
||||
logger.Error(err, "failed to update BackupJob status")
|
||||
}
|
||||
return ctrl.Result{}, nil
|
||||
}
|
||||
|
||||
veleroBackup := veleroBackupList.Items[0].DeepCopy()
|
||||
logger.Debug("found existing Velero Backup", "phase", veleroBackup.Status.Phase)
|
||||
|
||||
// If Velero backup exists but phase is not Running, set it to Running
|
||||
// This handles the case where the backup was created but phase wasn't set yet
|
||||
if j.Status.Phase != backupsv1alpha1.BackupJobPhaseRunning {
|
||||
logger.Debug("setting BackupJob phase to Running (Velero backup already exists)")
|
||||
j.Status.Phase = backupsv1alpha1.BackupJobPhaseRunning
|
||||
if err := r.Status().Update(ctx, j); err != nil {
|
||||
logger.Error(err, "failed to update BackupJob phase to Running")
|
||||
return ctrl.Result{}, err
|
||||
}
|
||||
}
|
||||
|
||||
// Check Velero Backup status
|
||||
phase := string(veleroBackup.Status.Phase)
|
||||
if phase == "" {
|
||||
// Still in progress, requeue
|
||||
return ctrl.Result{RequeueAfter: defaultActiveJobPollingInterval}, nil
|
||||
}
|
||||
|
||||
// Step 4: On success - Create Backup resource and update status
|
||||
if phase == "Completed" {
|
||||
// Check if we already created the Backup resource
|
||||
if j.Status.BackupRef == nil {
|
||||
backup, err := r.createBackupResource(ctx, j, veleroBackup)
|
||||
if err != nil {
|
||||
return r.markBackupJobFailed(ctx, j, fmt.Sprintf("failed to create Backup resource: %v", err))
|
||||
}
|
||||
|
||||
now := metav1.Now()
|
||||
j.Status.BackupRef = &corev1.LocalObjectReference{Name: backup.Name}
|
||||
j.Status.CompletedAt = &now
|
||||
j.Status.Phase = backupsv1alpha1.BackupJobPhaseSucceeded
|
||||
if err := r.Status().Update(ctx, j); err != nil {
|
||||
logger.Error(err, "failed to update BackupJob status")
|
||||
return ctrl.Result{}, err
|
||||
}
|
||||
logger.Debug("BackupJob succeeded", "backup", backup.Name)
|
||||
}
|
||||
return ctrl.Result{}, nil
|
||||
}
|
||||
|
||||
// Step 5: On failure
|
||||
if phase == "Failed" || phase == "PartiallyFailed" {
|
||||
message := fmt.Sprintf("Velero Backup failed with phase: %s", phase)
|
||||
if len(veleroBackup.Status.ValidationErrors) > 0 {
|
||||
message = fmt.Sprintf("%s: %v", message, veleroBackup.Status.ValidationErrors)
|
||||
}
|
||||
return r.markBackupJobFailed(ctx, j, message)
|
||||
}
|
||||
|
||||
// Still in progress (InProgress, New, etc.)
|
||||
return ctrl.Result{RequeueAfter: 5 * time.Second}, nil
|
||||
}
|
||||
|
||||
// resolveBucketStorageRef discovers S3 credentials from a Bucket storageRef
|
||||
// It follows this flow:
|
||||
// 1. Get the Bucket resource (apps.cozystack.io/v1alpha1)
|
||||
// 2. Find the BucketAccess that references this bucket
|
||||
// 3. Get the secret from BucketAccess.spec.credentialsSecretName
|
||||
// 4. Decode BucketInfo from secret.data.BucketInfo and extract S3 credentials
|
||||
func (r *BackupJobReconciler) resolveBucketStorageRef(ctx context.Context, storageRef corev1.TypedLocalObjectReference, namespace string) (*S3Credentials, error) {
|
||||
logger := getLogger(ctx)
|
||||
|
||||
// Step 1: Get the Bucket resource
|
||||
bucket := &unstructured.Unstructured{}
|
||||
bucket.SetGroupVersionKind(schema.GroupVersionKind{
|
||||
Group: *storageRef.APIGroup,
|
||||
Version: "v1alpha1",
|
||||
Kind: storageRef.Kind,
|
||||
})
|
||||
|
||||
if *storageRef.APIGroup != "apps.cozystack.io" {
|
||||
return nil, fmt.Errorf("Unsupported storage APIGroup: %v, expected apps.cozystack.io", storageRef.APIGroup)
|
||||
}
|
||||
bucketKey := client.ObjectKey{Namespace: namespace, Name: storageRef.Name}
|
||||
|
||||
if err := r.Get(ctx, bucketKey, bucket); err != nil {
|
||||
return nil, fmt.Errorf("failed to get Bucket %s: %w", storageRef.Name, err)
|
||||
}
|
||||
|
||||
// Step 2: Determine the bucket claim name
|
||||
// For apps.cozystack.io Bucket, the BucketClaim name is typically the same as the Bucket name
|
||||
// or follows a pattern. Based on the templates, it's usually the Release.Name which equals the Bucket name
|
||||
bucketName := storageRef.Name
|
||||
|
||||
// Step 3: Get BucketAccess by name (assuming BucketAccess name matches bucketName)
|
||||
bucketAccess := &unstructured.Unstructured{}
|
||||
bucketAccess.SetGroupVersionKind(schema.GroupVersionKind{
|
||||
Group: "objectstorage.k8s.io",
|
||||
Version: "v1alpha1",
|
||||
Kind: "BucketAccess",
|
||||
})
|
||||
|
||||
bucketAccessKey := client.ObjectKey{Name: "bucket-" + bucketName, Namespace: namespace}
|
||||
if err := r.Get(ctx, bucketAccessKey, bucketAccess); err != nil {
|
||||
return nil, fmt.Errorf("failed to get BucketAccess %s in namespace %s: %w", bucketName, namespace, err)
|
||||
}
|
||||
|
||||
// Step 4: Get the secret name from BucketAccess
|
||||
secretName, found, err := unstructured.NestedString(bucketAccess.Object, "spec", "credentialsSecretName")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get credentialsSecretName from BucketAccess: %w", err)
|
||||
}
|
||||
if !found || secretName == "" {
|
||||
return nil, fmt.Errorf("credentialsSecretName not found in BucketAccess %s", bucketAccessKey.Name)
|
||||
}
|
||||
|
||||
// Step 5: Get the secret
|
||||
secret := &corev1.Secret{}
|
||||
secretKey := client.ObjectKey{Namespace: namespace, Name: secretName}
|
||||
if err := r.Get(ctx, secretKey, secret); err != nil {
|
||||
return nil, fmt.Errorf("failed to get secret %s: %w", secretName, err)
|
||||
}
|
||||
|
||||
// Step 6: Decode BucketInfo from secret.data.BucketInfo
|
||||
bucketInfoData, found := secret.Data["BucketInfo"]
|
||||
if !found {
|
||||
return nil, fmt.Errorf("BucketInfo key not found in secret %s", secretName)
|
||||
}
|
||||
|
||||
// Parse JSON value
|
||||
var info bucketInfo
|
||||
if err := json.Unmarshal(bucketInfoData, &info); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal BucketInfo from secret %s: %w", secretName, err)
|
||||
}
|
||||
|
||||
// Step 7: Extract and return S3 credentials
|
||||
creds := &S3Credentials{
|
||||
BucketName: info.Spec.BucketName,
|
||||
Endpoint: info.Spec.SecretS3.Endpoint,
|
||||
Region: info.Spec.SecretS3.Region,
|
||||
AccessKeyID: info.Spec.SecretS3.AccessKeyID,
|
||||
AccessSecretKey: info.Spec.SecretS3.AccessSecretKey,
|
||||
}
|
||||
|
||||
logger.Debug("resolved S3 credentials from Bucket storageRef",
|
||||
"bucket", storageRef.Name,
|
||||
"bucketName", creds.BucketName,
|
||||
"endpoint", creds.Endpoint)
|
||||
|
||||
return creds, nil
|
||||
}
|
||||
|
||||
// createS3CredsForVelero creates or updates a Kubernetes Secret containing
|
||||
// Velero S3 credentials in the format expected by Velero's cloud-credentials plugin.
|
||||
func (r *BackupJobReconciler) createS3CredsForVelero(ctx context.Context, backupJob *backupsv1alpha1.BackupJob, creds *S3Credentials) error {
|
||||
logger := getLogger(ctx)
|
||||
secretName := storageS3SecretName(backupJob.Namespace, backupJob.Name)
|
||||
secretNamespace := veleroNamespace
|
||||
|
||||
secret := &corev1.Secret{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: secretName,
|
||||
Namespace: secretNamespace,
|
||||
},
|
||||
Type: corev1.SecretTypeOpaque,
|
||||
StringData: map[string]string{
|
||||
"cloud": fmt.Sprintf(`[default]
|
||||
aws_access_key_id=%s
|
||||
aws_secret_access_key=%s
|
||||
|
||||
services = seaweed-s3
|
||||
[services seaweed-s3]
|
||||
s3 =
|
||||
endpoint_url = %s
|
||||
`, creds.AccessKeyID, creds.AccessSecretKey, creds.Endpoint),
|
||||
},
|
||||
}
|
||||
|
||||
foundSecret := &corev1.Secret{}
|
||||
secretKey := client.ObjectKey{Name: secretName, Namespace: secretNamespace}
|
||||
err := r.Get(ctx, secretKey, foundSecret)
|
||||
if err != nil && errors.IsNotFound(err) {
|
||||
// Create the Secret
|
||||
if err := r.Create(ctx, secret); err != nil {
|
||||
r.Recorder.Event(backupJob, corev1.EventTypeWarning, "SecretCreationFailed",
|
||||
fmt.Sprintf("Failed to create Velero credentials secret %s/%s: %v", secretNamespace, secretName, err))
|
||||
return fmt.Errorf("failed to create Velero credentials secret: %w", err)
|
||||
}
|
||||
logger.Debug("created Velero credentials secret", "secret", secretName)
|
||||
r.Recorder.Event(backupJob, corev1.EventTypeNormal, "SecretCreated",
|
||||
fmt.Sprintf("Created Velero credentials secret %s/%s", secretNamespace, secretName))
|
||||
} else if err == nil {
|
||||
// Update if necessary - only update if the secret data has actually changed
|
||||
// Compare the new secret data with existing secret data
|
||||
existingData := foundSecret.Data
|
||||
if existingData == nil {
|
||||
existingData = make(map[string][]byte)
|
||||
}
|
||||
newData := make(map[string][]byte)
|
||||
for k, v := range secret.StringData {
|
||||
newData[k] = []byte(v)
|
||||
}
|
||||
|
||||
// Check if data has changed
|
||||
dataChanged := false
|
||||
if len(existingData) != len(newData) {
|
||||
dataChanged = true
|
||||
} else {
|
||||
for k, newVal := range newData {
|
||||
existingVal, exists := existingData[k]
|
||||
if !exists || !reflect.DeepEqual(existingVal, newVal) {
|
||||
dataChanged = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if dataChanged {
|
||||
foundSecret.StringData = secret.StringData
|
||||
foundSecret.Data = nil // Clear .Data so .StringData will be used
|
||||
if err := r.Update(ctx, foundSecret); err != nil {
|
||||
r.Recorder.Event(backupJob, corev1.EventTypeWarning, "SecretUpdateFailed",
|
||||
fmt.Sprintf("Failed to update Velero credentials secret %s/%s: %v", secretNamespace, secretName, err))
|
||||
return fmt.Errorf("failed to update Velero credentials secret: %w", err)
|
||||
}
|
||||
logger.Debug("updated Velero credentials secret", "secret", secretName)
|
||||
r.Recorder.Event(backupJob, corev1.EventTypeNormal, "SecretUpdated",
|
||||
fmt.Sprintf("Updated Velero credentials secret %s/%s", secretNamespace, secretName))
|
||||
} else {
|
||||
logger.Debug("Velero credentials secret data unchanged, skipping update", "secret", secretName)
|
||||
}
|
||||
} else if err != nil {
|
||||
return fmt.Errorf("error checking for existing Velero credentials secret: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// createBackupStorageLocation creates or updates a Velero BackupStorageLocation resource.
|
||||
func (r *BackupJobReconciler) createBackupStorageLocation(ctx context.Context, bsl *velerov1.BackupStorageLocation) error {
|
||||
logger := getLogger(ctx)
|
||||
foundBSL := &velerov1.BackupStorageLocation{}
|
||||
bslKey := client.ObjectKey{Name: bsl.Name, Namespace: bsl.Namespace}
|
||||
|
||||
err := r.Get(ctx, bslKey, foundBSL)
|
||||
if err != nil && errors.IsNotFound(err) {
|
||||
// Create the BackupStorageLocation
|
||||
if err := r.Create(ctx, bsl); err != nil {
|
||||
return fmt.Errorf("failed to create BackupStorageLocation: %w", err)
|
||||
}
|
||||
logger.Debug("created BackupStorageLocation", "name", bsl.Name, "namespace", bsl.Namespace)
|
||||
} else if err == nil {
|
||||
// Update if necessary - use patch to avoid conflicts with Velero's status updates
|
||||
// Only update if the spec has actually changed
|
||||
if !reflect.DeepEqual(foundBSL.Spec, bsl.Spec) {
|
||||
// Retry on conflict since Velero may be updating status concurrently
|
||||
for i := 0; i < 3; i++ {
|
||||
if err := r.Get(ctx, bslKey, foundBSL); err != nil {
|
||||
return fmt.Errorf("failed to get BackupStorageLocation for update: %w", err)
|
||||
}
|
||||
foundBSL.Spec = bsl.Spec
|
||||
if err := r.Update(ctx, foundBSL); err != nil {
|
||||
if errors.IsConflict(err) && i < 2 {
|
||||
logger.Debug("conflict updating BackupStorageLocation, retrying", "attempt", i+1)
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
continue
|
||||
}
|
||||
return fmt.Errorf("failed to update BackupStorageLocation: %w", err)
|
||||
}
|
||||
logger.Debug("updated BackupStorageLocation", "name", bsl.Name, "namespace", bsl.Namespace)
|
||||
return nil
|
||||
}
|
||||
} else {
|
||||
logger.Debug("BackupStorageLocation spec unchanged, skipping update", "name", bsl.Name, "namespace", bsl.Namespace)
|
||||
}
|
||||
} else if err != nil {
|
||||
return fmt.Errorf("error checking for existing BackupStorageLocation: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// createVolumeSnapshotLocation creates or updates a Velero VolumeSnapshotLocation resource.
|
||||
func (r *BackupJobReconciler) createVolumeSnapshotLocation(ctx context.Context, vsl *velerov1.VolumeSnapshotLocation) error {
|
||||
logger := getLogger(ctx)
|
||||
foundVSL := &velerov1.VolumeSnapshotLocation{}
|
||||
vslKey := client.ObjectKey{Name: vsl.Name, Namespace: vsl.Namespace}
|
||||
|
||||
err := r.Get(ctx, vslKey, foundVSL)
|
||||
if err != nil && errors.IsNotFound(err) {
|
||||
// Create the VolumeSnapshotLocation
|
||||
if err := r.Create(ctx, vsl); err != nil {
|
||||
return fmt.Errorf("failed to create VolumeSnapshotLocation: %w", err)
|
||||
}
|
||||
logger.Debug("created VolumeSnapshotLocation", "name", vsl.Name, "namespace", vsl.Namespace)
|
||||
} else if err == nil {
|
||||
// Update if necessary - only update if the spec has actually changed
|
||||
if !reflect.DeepEqual(foundVSL.Spec, vsl.Spec) {
|
||||
// Retry on conflict since Velero may be updating status concurrently
|
||||
for i := 0; i < 3; i++ {
|
||||
if err := r.Get(ctx, vslKey, foundVSL); err != nil {
|
||||
return fmt.Errorf("failed to get VolumeSnapshotLocation for update: %w", err)
|
||||
}
|
||||
foundVSL.Spec = vsl.Spec
|
||||
if err := r.Update(ctx, foundVSL); err != nil {
|
||||
if errors.IsConflict(err) && i < 2 {
|
||||
logger.Debug("conflict updating VolumeSnapshotLocation, retrying", "attempt", i+1)
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
continue
|
||||
}
|
||||
return fmt.Errorf("failed to update VolumeSnapshotLocation: %w", err)
|
||||
}
|
||||
logger.Debug("updated VolumeSnapshotLocation", "name", vsl.Name, "namespace", vsl.Namespace)
|
||||
return nil
|
||||
}
|
||||
} else {
|
||||
logger.Debug("VolumeSnapshotLocation spec unchanged, skipping update", "name", vsl.Name, "namespace", vsl.Namespace)
|
||||
}
|
||||
} else if err != nil {
|
||||
return fmt.Errorf("error checking for existing VolumeSnapshotLocation: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *BackupJobReconciler) markBackupJobFailed(ctx context.Context, backupJob *backupsv1alpha1.BackupJob, message string) (ctrl.Result, error) {
|
||||
logger := getLogger(ctx)
|
||||
now := metav1.Now()
|
||||
backupJob.Status.CompletedAt = &now
|
||||
backupJob.Status.Phase = backupsv1alpha1.BackupJobPhaseFailed
|
||||
backupJob.Status.Message = message
|
||||
|
||||
// Add condition
|
||||
backupJob.Status.Conditions = append(backupJob.Status.Conditions, metav1.Condition{
|
||||
Type: "Ready",
|
||||
Status: metav1.ConditionFalse,
|
||||
Reason: "BackupFailed",
|
||||
Message: message,
|
||||
LastTransitionTime: now,
|
||||
})
|
||||
|
||||
if err := r.Status().Update(ctx, backupJob); err != nil {
|
||||
logger.Error(err, "failed to update BackupJob status to Failed")
|
||||
return ctrl.Result{}, err
|
||||
}
|
||||
logger.Debug("BackupJob failed", "message", message)
|
||||
return ctrl.Result{}, nil
|
||||
}
|
||||
|
||||
func (r *BackupJobReconciler) createVeleroBackup(ctx context.Context, backupJob *backupsv1alpha1.BackupJob, strategy *strategyv1alpha1.Velero) error {
|
||||
logger := getLogger(ctx)
|
||||
logger.Debug("createVeleroBackup called", "strategy", strategy.Name)
|
||||
|
||||
mapping, err := r.RESTMapping(schema.GroupKind{Group: *backupJob.Spec.ApplicationRef.APIGroup, Kind: backupJob.Spec.ApplicationRef.Kind})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ns := backupJob.Namespace
|
||||
if mapping.Scope.Name() != meta.RESTScopeNameNamespace {
|
||||
ns = ""
|
||||
}
|
||||
app, err := r.Resource(mapping.Resource).Namespace(ns).Get(ctx, backupJob.Spec.ApplicationRef.Name, metav1.GetOptions{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
veleroBackupSpec, err := template.Template(&strategy.Spec.Template.Spec, app.Object)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
veleroBackup := &velerov1.Backup{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
GenerateName: fmt.Sprintf("%s.%s-", backupJob.Namespace, backupJob.Name),
|
||||
Namespace: veleroNamespace,
|
||||
Labels: map[string]string{
|
||||
backupsv1alpha1.OwningJobNameLabel: backupJob.Name,
|
||||
backupsv1alpha1.OwningJobNamespaceLabel: backupJob.Namespace,
|
||||
},
|
||||
},
|
||||
Spec: *veleroBackupSpec,
|
||||
}
|
||||
name := veleroBackup.GenerateName
|
||||
if err := r.Create(ctx, veleroBackup); err != nil {
|
||||
if veleroBackup.Name != "" {
|
||||
name = veleroBackup.Name
|
||||
}
|
||||
logger.Error(err, "failed to create Velero Backup", "name", veleroBackup.Name)
|
||||
r.Recorder.Event(backupJob, corev1.EventTypeWarning, "VeleroBackupCreationFailed",
|
||||
fmt.Sprintf("Failed to create Velero Backup %s/%s: %v", veleroNamespace, name, err))
|
||||
return err
|
||||
}
|
||||
|
||||
logger.Debug("created Velero Backup", "name", veleroBackup.Name, "namespace", veleroBackup.Namespace)
|
||||
r.Recorder.Event(backupJob, corev1.EventTypeNormal, "VeleroBackupCreated",
|
||||
fmt.Sprintf("Created Velero Backup %s/%s", veleroNamespace, name))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *BackupJobReconciler) createBackupResource(ctx context.Context, backupJob *backupsv1alpha1.BackupJob, veleroBackup *velerov1.Backup) (*backupsv1alpha1.Backup, error) {
|
||||
logger := getLogger(ctx)
|
||||
// Extract artifact information from Velero Backup
|
||||
// Create a basic artifact referencing the Velero backup
|
||||
artifact := &backupsv1alpha1.BackupArtifact{
|
||||
URI: fmt.Sprintf("velero://%s/%s", backupJob.Namespace, veleroBackup.Name),
|
||||
}
|
||||
|
||||
// Get takenAt from Velero Backup creation timestamp or status
|
||||
takenAt := metav1.Now()
|
||||
if veleroBackup.Status.StartTimestamp != nil {
|
||||
takenAt = *veleroBackup.Status.StartTimestamp
|
||||
} else if !veleroBackup.CreationTimestamp.IsZero() {
|
||||
takenAt = veleroBackup.CreationTimestamp
|
||||
}
|
||||
|
||||
// Extract driver metadata (e.g., Velero backup name)
|
||||
driverMetadata := map[string]string{
|
||||
"velero.io/backup-name": veleroBackup.Name,
|
||||
"velero.io/backup-namespace": veleroBackup.Namespace,
|
||||
}
|
||||
|
||||
backup := &backupsv1alpha1.Backup{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: fmt.Sprintf("%s", backupJob.Name),
|
||||
Namespace: backupJob.Namespace,
|
||||
OwnerReferences: []metav1.OwnerReference{
|
||||
{
|
||||
APIVersion: backupJob.APIVersion,
|
||||
Kind: backupJob.Kind,
|
||||
Name: backupJob.Name,
|
||||
UID: backupJob.UID,
|
||||
Controller: boolPtr(true),
|
||||
},
|
||||
},
|
||||
},
|
||||
Spec: backupsv1alpha1.BackupSpec{
|
||||
ApplicationRef: backupJob.Spec.ApplicationRef,
|
||||
StorageRef: backupJob.Spec.StorageRef,
|
||||
StrategyRef: backupJob.Spec.StrategyRef,
|
||||
TakenAt: takenAt,
|
||||
DriverMetadata: driverMetadata,
|
||||
},
|
||||
Status: backupsv1alpha1.BackupStatus{
|
||||
Phase: backupsv1alpha1.BackupPhaseReady,
|
||||
},
|
||||
}
|
||||
|
||||
if backupJob.Spec.PlanRef != nil {
|
||||
backup.Spec.PlanRef = backupJob.Spec.PlanRef
|
||||
}
|
||||
|
||||
if artifact != nil {
|
||||
backup.Status.Artifact = artifact
|
||||
}
|
||||
|
||||
if err := r.Create(ctx, backup); err != nil {
|
||||
logger.Error(err, "failed to create Backup resource")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
logger.Debug("created Backup resource", "name", backup.Name)
|
||||
return backup, nil
|
||||
}
|
||||
@@ -37,6 +37,8 @@ type CozystackResourceDefinitionReconciler struct {
|
||||
}
|
||||
|
||||
func (r *CozystackResourceDefinitionReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
|
||||
// Only handle debounced restart logic
|
||||
// HelmRelease reconciliation is handled by CozystackResourceDefinitionHelmReconciler
|
||||
return r.debouncedRestart(ctx)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
cozyv1alpha1 "github.com/cozystack/cozystack/api/v1alpha1"
|
||||
helmv2 "github.com/fluxcd/helm-controller/api/v2"
|
||||
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
|
||||
ctrl "sigs.k8s.io/controller-runtime"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
"sigs.k8s.io/controller-runtime/pkg/log"
|
||||
)
|
||||
|
||||
// +kubebuilder:rbac:groups=cozystack.io,resources=cozystackresourcedefinitions,verbs=get;list;watch
|
||||
// +kubebuilder:rbac:groups=helm.toolkit.fluxcd.io,resources=helmreleases,verbs=get;list;watch;update;patch
|
||||
|
||||
// CozystackResourceDefinitionHelmReconciler reconciles CozystackResourceDefinitions
|
||||
// and updates related HelmReleases when a CozyRD changes.
|
||||
// This controller does NOT watch HelmReleases to avoid mutual reconciliation storms
|
||||
// with Flux's helm-controller.
|
||||
type CozystackResourceDefinitionHelmReconciler struct {
|
||||
client.Client
|
||||
Scheme *runtime.Scheme
|
||||
}
|
||||
|
||||
func (r *CozystackResourceDefinitionHelmReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
|
||||
logger := log.FromContext(ctx)
|
||||
|
||||
// Get the CozystackResourceDefinition that triggered this reconciliation
|
||||
crd := &cozyv1alpha1.CozystackResourceDefinition{}
|
||||
if err := r.Get(ctx, req.NamespacedName, crd); err != nil {
|
||||
logger.Error(err, "failed to get CozystackResourceDefinition", "name", req.Name)
|
||||
return ctrl.Result{}, client.IgnoreNotFound(err)
|
||||
}
|
||||
|
||||
// Update HelmReleases related to this specific CozyRD
|
||||
if err := r.updateHelmReleasesForCRD(ctx, crd); err != nil {
|
||||
logger.Error(err, "failed to update HelmReleases for CRD", "crd", crd.Name)
|
||||
return ctrl.Result{}, err
|
||||
}
|
||||
|
||||
return ctrl.Result{}, nil
|
||||
}
|
||||
|
||||
func (r *CozystackResourceDefinitionHelmReconciler) SetupWithManager(mgr ctrl.Manager) error {
|
||||
return ctrl.NewControllerManagedBy(mgr).
|
||||
Named("cozystackresourcedefinition-helm-reconciler").
|
||||
For(&cozyv1alpha1.CozystackResourceDefinition{}).
|
||||
Complete(r)
|
||||
}
|
||||
|
||||
// updateHelmReleasesForCRD updates all HelmReleases that match the application labels from CozystackResourceDefinition
|
||||
func (r *CozystackResourceDefinitionHelmReconciler) updateHelmReleasesForCRD(ctx context.Context, crd *cozyv1alpha1.CozystackResourceDefinition) error {
|
||||
logger := log.FromContext(ctx)
|
||||
|
||||
// Use application labels to find HelmReleases
|
||||
// Labels: apps.cozystack.io/application.kind and apps.cozystack.io/application.group
|
||||
applicationKind := crd.Spec.Application.Kind
|
||||
|
||||
// Validate that applicationKind is non-empty
|
||||
if applicationKind == "" {
|
||||
logger.V(4).Info("Skipping HelmRelease update: Application.Kind is empty", "crd", crd.Name)
|
||||
return nil
|
||||
}
|
||||
|
||||
applicationGroup := "apps.cozystack.io" // All applications use this group
|
||||
|
||||
// Build label selector for HelmReleases
|
||||
// Only reconcile HelmReleases with cozystack.io/ui=true label
|
||||
labelSelector := client.MatchingLabels{
|
||||
"apps.cozystack.io/application.kind": applicationKind,
|
||||
"apps.cozystack.io/application.group": applicationGroup,
|
||||
"cozystack.io/ui": "true",
|
||||
}
|
||||
|
||||
// List all HelmReleases with matching labels
|
||||
hrList := &helmv2.HelmReleaseList{}
|
||||
if err := r.List(ctx, hrList, labelSelector); err != nil {
|
||||
logger.Error(err, "failed to list HelmReleases", "kind", applicationKind, "group", applicationGroup)
|
||||
return err
|
||||
}
|
||||
|
||||
logger.V(4).Info("Found HelmReleases to update", "crd", crd.Name, "kind", applicationKind, "count", len(hrList.Items))
|
||||
|
||||
// Update each HelmRelease
|
||||
for i := range hrList.Items {
|
||||
hr := &hrList.Items[i]
|
||||
if err := r.updateHelmReleaseChart(ctx, hr, crd); err != nil {
|
||||
logger.Error(err, "failed to update HelmRelease", "name", hr.Name, "namespace", hr.Namespace)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// expectedValuesFrom returns the expected valuesFrom configuration for HelmReleases
|
||||
func expectedValuesFrom() []helmv2.ValuesReference {
|
||||
return []helmv2.ValuesReference{
|
||||
{
|
||||
Kind: "Secret",
|
||||
Name: "cozystack-values",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// valuesFromEqual compares two ValuesReference slices
|
||||
func valuesFromEqual(a, b []helmv2.ValuesReference) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i].Kind != b[i].Kind ||
|
||||
a[i].Name != b[i].Name ||
|
||||
a[i].ValuesKey != b[i].ValuesKey ||
|
||||
a[i].TargetPath != b[i].TargetPath ||
|
||||
a[i].Optional != b[i].Optional {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// updateHelmReleaseChart updates the chart and valuesFrom in HelmRelease based on CozystackResourceDefinition
|
||||
func (r *CozystackResourceDefinitionHelmReconciler) updateHelmReleaseChart(ctx context.Context, hr *helmv2.HelmRelease, crd *cozyv1alpha1.CozystackResourceDefinition) error {
|
||||
logger := log.FromContext(ctx)
|
||||
hrCopy := hr.DeepCopy()
|
||||
updated := false
|
||||
|
||||
// Validate ChartRef configuration exists
|
||||
if crd.Spec.Release.ChartRef == nil ||
|
||||
crd.Spec.Release.ChartRef.Kind == "" ||
|
||||
crd.Spec.Release.ChartRef.Name == "" ||
|
||||
crd.Spec.Release.ChartRef.Namespace == "" {
|
||||
logger.Error(fmt.Errorf("invalid ChartRef in CRD"), "Skipping HelmRelease chartRef update: ChartRef is nil or incomplete",
|
||||
"crd", crd.Name)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Use ChartRef directly from CRD
|
||||
expectedChartRef := crd.Spec.Release.ChartRef
|
||||
|
||||
// Check if chartRef needs to be updated
|
||||
if hrCopy.Spec.ChartRef == nil {
|
||||
hrCopy.Spec.ChartRef = expectedChartRef
|
||||
// Clear the old chart field when switching to chartRef
|
||||
hrCopy.Spec.Chart = nil
|
||||
updated = true
|
||||
} else if hrCopy.Spec.ChartRef.Kind != expectedChartRef.Kind ||
|
||||
hrCopy.Spec.ChartRef.Name != expectedChartRef.Name ||
|
||||
hrCopy.Spec.ChartRef.Namespace != expectedChartRef.Namespace {
|
||||
hrCopy.Spec.ChartRef = expectedChartRef
|
||||
updated = true
|
||||
}
|
||||
|
||||
// Check and update valuesFrom configuration
|
||||
expected := expectedValuesFrom()
|
||||
if !valuesFromEqual(hrCopy.Spec.ValuesFrom, expected) {
|
||||
logger.V(4).Info("Updating HelmRelease valuesFrom", "name", hr.Name, "namespace", hr.Namespace)
|
||||
hrCopy.Spec.ValuesFrom = expected
|
||||
updated = true
|
||||
}
|
||||
|
||||
if updated {
|
||||
logger.V(4).Info("Updating HelmRelease chartRef", "name", hr.Name, "namespace", hr.Namespace)
|
||||
if err := r.Update(ctx, hrCopy); err != nil {
|
||||
return fmt.Errorf("failed to update HelmRelease: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -105,8 +105,26 @@ func buildMultilineStringSchema(openAPISchema string) (map[string]any, error) {
|
||||
"properties": map[string]any{},
|
||||
}
|
||||
|
||||
// Check if there's a spec property
|
||||
specProp, ok := props["spec"].(map[string]any)
|
||||
if !ok {
|
||||
return map[string]any{}, nil
|
||||
}
|
||||
|
||||
specProps, ok := specProp["properties"].(map[string]any)
|
||||
if !ok {
|
||||
return map[string]any{}, nil
|
||||
}
|
||||
|
||||
// Create spec.properties structure in schema
|
||||
schemaProps := schema["properties"].(map[string]any)
|
||||
specSchema := map[string]any{
|
||||
"properties": map[string]any{},
|
||||
}
|
||||
schemaProps["spec"] = specSchema
|
||||
|
||||
// Process spec properties recursively
|
||||
processSpecProperties(props, schema["properties"].(map[string]any))
|
||||
processSpecProperties(specProps, specSchema["properties"].(map[string]any))
|
||||
|
||||
return schema, nil
|
||||
}
|
||||
|
||||
@@ -9,41 +9,46 @@ func TestBuildMultilineStringSchema(t *testing.T) {
|
||||
// Test OpenAPI schema with various field types
|
||||
openAPISchema := `{
|
||||
"properties": {
|
||||
"simpleString": {
|
||||
"type": "string",
|
||||
"description": "A simple string field"
|
||||
},
|
||||
"stringWithEnum": {
|
||||
"type": "string",
|
||||
"enum": ["option1", "option2"],
|
||||
"description": "String with enum should be skipped"
|
||||
},
|
||||
"numberField": {
|
||||
"type": "number",
|
||||
"description": "Number field should be skipped"
|
||||
},
|
||||
"nestedObject": {
|
||||
"spec": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"nestedString": {
|
||||
"simpleString": {
|
||||
"type": "string",
|
||||
"description": "Nested string should get multilineString"
|
||||
"description": "A simple string field"
|
||||
},
|
||||
"nestedStringWithEnum": {
|
||||
"stringWithEnum": {
|
||||
"type": "string",
|
||||
"enum": ["a", "b"],
|
||||
"description": "Nested string with enum should be skipped"
|
||||
}
|
||||
}
|
||||
},
|
||||
"arrayOfObjects": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"itemString": {
|
||||
"type": "string",
|
||||
"description": "String in array item"
|
||||
"enum": ["option1", "option2"],
|
||||
"description": "String with enum should be skipped"
|
||||
},
|
||||
"numberField": {
|
||||
"type": "number",
|
||||
"description": "Number field should be skipped"
|
||||
},
|
||||
"nestedObject": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"nestedString": {
|
||||
"type": "string",
|
||||
"description": "Nested string should get multilineString"
|
||||
},
|
||||
"nestedStringWithEnum": {
|
||||
"type": "string",
|
||||
"enum": ["a", "b"],
|
||||
"description": "Nested string with enum should be skipped"
|
||||
}
|
||||
}
|
||||
},
|
||||
"arrayOfObjects": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"itemString": {
|
||||
"type": "string",
|
||||
"description": "String in array item"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -70,33 +75,44 @@ func TestBuildMultilineStringSchema(t *testing.T) {
|
||||
t.Fatal("schema.properties is not a map")
|
||||
}
|
||||
|
||||
// Check simpleString
|
||||
simpleString, ok := props["simpleString"].(map[string]any)
|
||||
// Check spec property exists
|
||||
spec, ok := props["spec"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatal("simpleString not found in properties")
|
||||
t.Fatal("spec not found in properties")
|
||||
}
|
||||
|
||||
specProps, ok := spec["properties"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatal("spec.properties is not a map")
|
||||
}
|
||||
|
||||
// Check simpleString
|
||||
simpleString, ok := specProps["simpleString"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatal("simpleString not found in spec.properties")
|
||||
}
|
||||
if simpleString["type"] != "multilineString" {
|
||||
t.Errorf("simpleString should have type multilineString, got %v", simpleString["type"])
|
||||
}
|
||||
|
||||
// Check stringWithEnum should not be present (or should not have multilineString)
|
||||
if stringWithEnum, ok := props["stringWithEnum"].(map[string]any); ok {
|
||||
if stringWithEnum, ok := specProps["stringWithEnum"].(map[string]any); ok {
|
||||
if stringWithEnum["type"] == "multilineString" {
|
||||
t.Error("stringWithEnum should not have multilineString type")
|
||||
}
|
||||
}
|
||||
|
||||
// Check numberField should not be present
|
||||
if numberField, ok := props["numberField"].(map[string]any); ok {
|
||||
if numberField, ok := specProps["numberField"].(map[string]any); ok {
|
||||
if numberField["type"] != nil {
|
||||
t.Error("numberField should not have any type override")
|
||||
}
|
||||
}
|
||||
|
||||
// Check nested object
|
||||
nestedObject, ok := props["nestedObject"].(map[string]any)
|
||||
nestedObject, ok := specProps["nestedObject"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatal("nestedObject not found in properties")
|
||||
t.Fatal("nestedObject not found in spec.properties")
|
||||
}
|
||||
nestedProps, ok := nestedObject["properties"].(map[string]any)
|
||||
if !ok {
|
||||
@@ -113,9 +129,9 @@ func TestBuildMultilineStringSchema(t *testing.T) {
|
||||
}
|
||||
|
||||
// Check array of objects
|
||||
arrayOfObjects, ok := props["arrayOfObjects"].(map[string]any)
|
||||
arrayOfObjects, ok := specProps["arrayOfObjects"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatal("arrayOfObjects not found in properties")
|
||||
t.Fatal("arrayOfObjects not found in spec.properties")
|
||||
}
|
||||
items, ok := arrayOfObjects["items"].(map[string]any)
|
||||
if !ok {
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
helmv2 "github.com/fluxcd/helm-controller/api/v2"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
kerrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
ctrl "sigs.k8s.io/controller-runtime"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
"sigs.k8s.io/controller-runtime/pkg/event"
|
||||
"sigs.k8s.io/controller-runtime/pkg/log"
|
||||
"sigs.k8s.io/controller-runtime/pkg/predicate"
|
||||
)
|
||||
|
||||
type CozystackConfigReconciler struct {
|
||||
client.Client
|
||||
Scheme *runtime.Scheme
|
||||
}
|
||||
|
||||
var configMapNames = []string{"cozystack", "cozystack-branding", "cozystack-scheduling"}
|
||||
|
||||
const configMapNamespace = "cozy-system"
|
||||
const digestAnnotation = "cozystack.io/cozy-config-digest"
|
||||
const forceReconcileKey = "reconcile.fluxcd.io/forceAt"
|
||||
const requestedAt = "reconcile.fluxcd.io/requestedAt"
|
||||
|
||||
func (r *CozystackConfigReconciler) Reconcile(ctx context.Context, _ ctrl.Request) (ctrl.Result, error) {
|
||||
log := log.FromContext(ctx)
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
digest, err := r.computeDigest(ctx)
|
||||
if err != nil {
|
||||
log.Error(err, "failed to compute config digest")
|
||||
return ctrl.Result{}, nil
|
||||
}
|
||||
|
||||
var helmList helmv2.HelmReleaseList
|
||||
if err := r.List(ctx, &helmList); err != nil {
|
||||
return ctrl.Result{}, fmt.Errorf("failed to list HelmReleases: %w", err)
|
||||
}
|
||||
|
||||
now := time.Now().Format(time.RFC3339Nano)
|
||||
updated := 0
|
||||
|
||||
for _, hr := range helmList.Items {
|
||||
isSystemApp := hr.Labels["cozystack.io/system-app"] == "true"
|
||||
isTenantRoot := hr.Namespace == "tenant-root" && hr.Name == "tenant-root"
|
||||
if !isSystemApp && !isTenantRoot {
|
||||
continue
|
||||
}
|
||||
patchTarget := hr.DeepCopy()
|
||||
|
||||
if hr.Annotations == nil {
|
||||
hr.Annotations = map[string]string{}
|
||||
}
|
||||
|
||||
if hr.Annotations[digestAnnotation] == digest {
|
||||
continue
|
||||
}
|
||||
patchTarget.Annotations[digestAnnotation] = digest
|
||||
patchTarget.Annotations[forceReconcileKey] = now
|
||||
patchTarget.Annotations[requestedAt] = now
|
||||
|
||||
patch := client.MergeFrom(hr.DeepCopy())
|
||||
if err := r.Patch(ctx, patchTarget, patch); err != nil {
|
||||
log.Error(err, "failed to patch HelmRelease", "name", hr.Name, "namespace", hr.Namespace)
|
||||
continue
|
||||
}
|
||||
updated++
|
||||
log.Info("patched HelmRelease with new config digest", "name", hr.Name, "namespace", hr.Namespace)
|
||||
}
|
||||
|
||||
log.Info("finished reconciliation", "updatedHelmReleases", updated)
|
||||
return ctrl.Result{}, nil
|
||||
}
|
||||
|
||||
func (r *CozystackConfigReconciler) computeDigest(ctx context.Context) (string, error) {
|
||||
hash := sha256.New()
|
||||
|
||||
for _, name := range configMapNames {
|
||||
var cm corev1.ConfigMap
|
||||
err := r.Get(ctx, client.ObjectKey{Namespace: configMapNamespace, Name: name}, &cm)
|
||||
if err != nil {
|
||||
if kerrors.IsNotFound(err) {
|
||||
continue // ignore missing
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Sort keys for consistent hashing
|
||||
var keys []string
|
||||
for k := range cm.Data {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
for _, k := range keys {
|
||||
v := cm.Data[k]
|
||||
fmt.Fprintf(hash, "%s:%s=%s\n", name, k, v)
|
||||
}
|
||||
}
|
||||
|
||||
return hex.EncodeToString(hash.Sum(nil)), nil
|
||||
}
|
||||
|
||||
func (r *CozystackConfigReconciler) SetupWithManager(mgr ctrl.Manager) error {
|
||||
return ctrl.NewControllerManagedBy(mgr).
|
||||
WithEventFilter(predicate.Funcs{
|
||||
UpdateFunc: func(e event.UpdateEvent) bool {
|
||||
cm, ok := e.ObjectNew.(*corev1.ConfigMap)
|
||||
return ok && cm.Namespace == configMapNamespace && contains(configMapNames, cm.Name)
|
||||
},
|
||||
CreateFunc: func(e event.CreateEvent) bool {
|
||||
cm, ok := e.Object.(*corev1.ConfigMap)
|
||||
return ok && cm.Namespace == configMapNamespace && contains(configMapNames, cm.Name)
|
||||
},
|
||||
DeleteFunc: func(e event.DeleteEvent) bool {
|
||||
cm, ok := e.Object.(*corev1.ConfigMap)
|
||||
return ok && cm.Namespace == configMapNamespace && contains(configMapNames, cm.Name)
|
||||
},
|
||||
}).
|
||||
For(&corev1.ConfigMap{}).
|
||||
Complete(r)
|
||||
}
|
||||
|
||||
func contains(slice []string, val string) bool {
|
||||
for _, s := range slice {
|
||||
if s == val {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
e "errors"
|
||||
|
||||
helmv2 "github.com/fluxcd/helm-controller/api/v2"
|
||||
"gopkg.in/yaml.v2"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
|
||||
"k8s.io/apimachinery/pkg/api/errors"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
ctrl "sigs.k8s.io/controller-runtime"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
"sigs.k8s.io/controller-runtime/pkg/log"
|
||||
)
|
||||
|
||||
type TenantHelmReconciler struct {
|
||||
client.Client
|
||||
Scheme *runtime.Scheme
|
||||
}
|
||||
|
||||
func (r *TenantHelmReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
|
||||
logger := log.FromContext(ctx)
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
hr := &helmv2.HelmRelease{}
|
||||
if err := r.Get(ctx, req.NamespacedName, hr); err != nil {
|
||||
if errors.IsNotFound(err) {
|
||||
return ctrl.Result{}, nil
|
||||
}
|
||||
logger.Error(err, "unable to fetch HelmRelease")
|
||||
return ctrl.Result{}, err
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(hr.Name, "tenant-") {
|
||||
return ctrl.Result{}, nil
|
||||
}
|
||||
|
||||
if len(hr.Status.Conditions) == 0 || hr.Status.Conditions[0].Type != "Ready" {
|
||||
return ctrl.Result{}, nil
|
||||
}
|
||||
|
||||
if len(hr.Status.History) == 0 {
|
||||
logger.Info("no history in HelmRelease status", "name", hr.Name)
|
||||
return ctrl.Result{}, nil
|
||||
}
|
||||
|
||||
if hr.Status.History[0].Status != "deployed" {
|
||||
return ctrl.Result{}, nil
|
||||
}
|
||||
|
||||
newDigest := hr.Status.History[0].Digest
|
||||
var hrList helmv2.HelmReleaseList
|
||||
childNamespace := getChildNamespace(hr.Namespace, hr.Name)
|
||||
if childNamespace == "tenant-root" && hr.Name == "tenant-root" {
|
||||
if hr.Spec.Values == nil {
|
||||
logger.Error(e.New("hr.Spec.Values is nil"), "cant annotate tenant-root ns")
|
||||
return ctrl.Result{}, nil
|
||||
}
|
||||
err := annotateTenantRootNs(*hr.Spec.Values, r.Client)
|
||||
if err != nil {
|
||||
logger.Error(err, "cant annotate tenant-root ns")
|
||||
return ctrl.Result{}, nil
|
||||
}
|
||||
logger.Info("namespace 'tenant-root' annotated")
|
||||
}
|
||||
|
||||
if err := r.List(ctx, &hrList, client.InNamespace(childNamespace)); err != nil {
|
||||
logger.Error(err, "unable to list HelmReleases in namespace", "namespace", hr.Name)
|
||||
return ctrl.Result{}, err
|
||||
}
|
||||
|
||||
for _, item := range hrList.Items {
|
||||
if item.Name == hr.Name {
|
||||
continue
|
||||
}
|
||||
oldDigest := item.GetAnnotations()["cozystack.io/tenant-config-digest"]
|
||||
if oldDigest == newDigest {
|
||||
continue
|
||||
}
|
||||
patchTarget := item.DeepCopy()
|
||||
|
||||
if patchTarget.Annotations == nil {
|
||||
patchTarget.Annotations = map[string]string{}
|
||||
}
|
||||
ts := time.Now().Format(time.RFC3339Nano)
|
||||
|
||||
patchTarget.Annotations["cozystack.io/tenant-config-digest"] = newDigest
|
||||
patchTarget.Annotations["reconcile.fluxcd.io/forceAt"] = ts
|
||||
patchTarget.Annotations["reconcile.fluxcd.io/requestedAt"] = ts
|
||||
|
||||
patch := client.MergeFrom(item.DeepCopy())
|
||||
if err := r.Patch(ctx, patchTarget, patch); err != nil {
|
||||
logger.Error(err, "failed to patch HelmRelease", "name", patchTarget.Name)
|
||||
continue
|
||||
}
|
||||
|
||||
logger.Info("patched HelmRelease with new digest", "name", patchTarget.Name, "digest", newDigest, "version", hr.Status.History[0].Version)
|
||||
}
|
||||
|
||||
return ctrl.Result{}, nil
|
||||
}
|
||||
|
||||
func (r *TenantHelmReconciler) SetupWithManager(mgr ctrl.Manager) error {
|
||||
return ctrl.NewControllerManagedBy(mgr).
|
||||
For(&helmv2.HelmRelease{}).
|
||||
Complete(r)
|
||||
}
|
||||
|
||||
func getChildNamespace(currentNamespace, hrName string) string {
|
||||
tenantName := strings.TrimPrefix(hrName, "tenant-")
|
||||
|
||||
switch {
|
||||
case currentNamespace == "tenant-root" && hrName == "tenant-root":
|
||||
// 1) root tenant inside root namespace
|
||||
return "tenant-root"
|
||||
|
||||
case currentNamespace == "tenant-root":
|
||||
// 2) any other tenant in root namespace
|
||||
return fmt.Sprintf("tenant-%s", tenantName)
|
||||
|
||||
default:
|
||||
// 3) tenant in a dedicated namespace
|
||||
return fmt.Sprintf("%s-%s", currentNamespace, tenantName)
|
||||
}
|
||||
}
|
||||
|
||||
func annotateTenantRootNs(values apiextensionsv1.JSON, c client.Client) error {
|
||||
var data map[string]interface{}
|
||||
if err := yaml.Unmarshal(values.Raw, &data); err != nil {
|
||||
return fmt.Errorf("failed to parse HelmRelease values: %w", err)
|
||||
}
|
||||
|
||||
host, ok := data["host"].(string)
|
||||
if !ok || host == "" {
|
||||
return fmt.Errorf("host field not found or not a string")
|
||||
}
|
||||
|
||||
var ns corev1.Namespace
|
||||
if err := c.Get(context.TODO(), client.ObjectKey{Name: "tenant-root"}, &ns); err != nil {
|
||||
return fmt.Errorf("failed to get namespace tenant-root: %w", err)
|
||||
}
|
||||
|
||||
if ns.Annotations == nil {
|
||||
ns.Annotations = map[string]string{}
|
||||
}
|
||||
ns.Annotations["namespace.cozystack.io/host"] = host
|
||||
|
||||
if err := c.Update(context.TODO(), &ns); err != nil {
|
||||
return fmt.Errorf("failed to update namespace: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
272
internal/cozyvaluesreplicator/cozyvaluesreplicator.go
Normal file
272
internal/cozyvaluesreplicator/cozyvaluesreplicator.go
Normal file
@@ -0,0 +1,272 @@
|
||||
/*
|
||||
Copyright 2025 The Cozystack 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 cozyvaluesreplicator
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/labels"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
ctrl "sigs.k8s.io/controller-runtime"
|
||||
"sigs.k8s.io/controller-runtime/pkg/builder"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
|
||||
"sigs.k8s.io/controller-runtime/pkg/event"
|
||||
"sigs.k8s.io/controller-runtime/pkg/handler"
|
||||
"sigs.k8s.io/controller-runtime/pkg/log"
|
||||
"sigs.k8s.io/controller-runtime/pkg/predicate"
|
||||
"sigs.k8s.io/controller-runtime/pkg/reconcile"
|
||||
)
|
||||
|
||||
// SecretReplicatorReconciler replicates a source secret to namespaces matching a label selector.
|
||||
type SecretReplicatorReconciler struct {
|
||||
client.Client
|
||||
Scheme *runtime.Scheme
|
||||
|
||||
// Source of truth:
|
||||
SourceNamespace string
|
||||
SecretName string
|
||||
|
||||
// Namespaces to replicate into:
|
||||
// (e.g. labels.SelectorFromSet(labels.Set{"tenant":"true"}), or metav1.LabelSelectorAsSelector(...))
|
||||
TargetNamespaceSelector labels.Selector
|
||||
}
|
||||
|
||||
func (r *SecretReplicatorReconciler) SetupWithManager(mgr ctrl.Manager) error {
|
||||
// 1) Primary watch for requirement (b):
|
||||
// Reconcile any Secret named r.SecretName in any namespace (includes source too).
|
||||
// This keeps Secrets in cache and causes "copy changed -> reconcile it" to happen.
|
||||
secretNameOnly := predicate.NewPredicateFuncs(func(obj client.Object) bool {
|
||||
return obj.GetName() == r.SecretName
|
||||
})
|
||||
|
||||
// 2) Secondary watch for requirement (c):
|
||||
// When the *source* Secret changes, fan-out reconcile requests to every matching namespace.
|
||||
onlySourceSecret := predicate.Funcs{
|
||||
CreateFunc: func(e event.CreateEvent) bool { return isSourceSecret(e.Object, r) },
|
||||
UpdateFunc: func(e event.UpdateEvent) bool { return isSourceSecret(e.ObjectNew, r) },
|
||||
DeleteFunc: func(e event.DeleteEvent) bool { return isSourceSecret(e.Object, r) },
|
||||
GenericFunc: func(e event.GenericEvent) bool {
|
||||
return isSourceSecret(e.Object, r)
|
||||
},
|
||||
}
|
||||
|
||||
// Fan-out mapper for source Secret events -> one request per matching target namespace.
|
||||
fanOutOnSourceSecret := handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, _ client.Object) []reconcile.Request {
|
||||
// List namespaces *from the cache* (because we also watch Namespaces below).
|
||||
var nsList corev1.NamespaceList
|
||||
if err := r.List(ctx, &nsList); err != nil {
|
||||
// If list fails, best-effort: return nothing; reconcile will be retried by next event.
|
||||
return nil
|
||||
}
|
||||
|
||||
reqs := make([]reconcile.Request, 0, len(nsList.Items))
|
||||
for i := range nsList.Items {
|
||||
ns := &nsList.Items[i]
|
||||
if ns.Name == r.SourceNamespace {
|
||||
continue
|
||||
}
|
||||
if r.TargetNamespaceSelector != nil && !r.TargetNamespaceSelector.Matches(labels.Set(ns.Labels)) {
|
||||
continue
|
||||
}
|
||||
reqs = append(reqs, reconcile.Request{
|
||||
NamespacedName: types.NamespacedName{
|
||||
Namespace: ns.Name,
|
||||
Name: r.SecretName,
|
||||
},
|
||||
})
|
||||
}
|
||||
return reqs
|
||||
})
|
||||
|
||||
// 3) Namespace watch for requirement (a):
|
||||
// When a namespace is created/updated to match selector, enqueue reconcile for the Secret copy in that namespace.
|
||||
enqueueOnNamespaceMatch := handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, obj client.Object) []reconcile.Request {
|
||||
ns, ok := obj.(*corev1.Namespace)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if ns.Name == r.SourceNamespace {
|
||||
return nil
|
||||
}
|
||||
if r.TargetNamespaceSelector != nil && !r.TargetNamespaceSelector.Matches(labels.Set(ns.Labels)) {
|
||||
return nil
|
||||
}
|
||||
return []reconcile.Request{{
|
||||
NamespacedName: types.NamespacedName{
|
||||
Namespace: ns.Name,
|
||||
Name: r.SecretName,
|
||||
},
|
||||
}}
|
||||
})
|
||||
|
||||
// Only trigger from namespace events where the label match may be (or become) true.
|
||||
// (You can keep this simple; it's fine if it fires on any update—your Reconcile should be idempotent.)
|
||||
namespaceMayMatter := predicate.Funcs{
|
||||
CreateFunc: func(e event.CreateEvent) bool {
|
||||
ns, ok := e.Object.(*corev1.Namespace)
|
||||
return ok && (r.TargetNamespaceSelector == nil || r.TargetNamespaceSelector.Matches(labels.Set(ns.Labels)))
|
||||
},
|
||||
UpdateFunc: func(e event.UpdateEvent) bool {
|
||||
oldNS, okOld := e.ObjectOld.(*corev1.Namespace)
|
||||
newNS, okNew := e.ObjectNew.(*corev1.Namespace)
|
||||
if !okOld || !okNew {
|
||||
return false
|
||||
}
|
||||
// Fire if it matches now OR matched before (covers transitions both ways; reconcile can decide what to do).
|
||||
oldMatch := r.TargetNamespaceSelector == nil || r.TargetNamespaceSelector.Matches(labels.Set(oldNS.Labels))
|
||||
newMatch := r.TargetNamespaceSelector == nil || r.TargetNamespaceSelector.Matches(labels.Set(newNS.Labels))
|
||||
return oldMatch || newMatch
|
||||
},
|
||||
DeleteFunc: func(event.DeleteEvent) bool { return false }, // nothing to do on namespace delete
|
||||
GenericFunc: func(event.GenericEvent) bool { return false },
|
||||
}
|
||||
|
||||
return ctrl.NewControllerManagedBy(mgr).
|
||||
// (b) Watch all Secrets with the chosen name; this also ensures Secret objects are cached.
|
||||
For(&corev1.Secret{}, builder.WithPredicates(secretNameOnly)).
|
||||
|
||||
// (c) Add a second watch on Secret, but only for the source secret, and fan-out to all namespaces.
|
||||
Watches(
|
||||
&corev1.Secret{},
|
||||
fanOutOnSourceSecret,
|
||||
builder.WithPredicates(onlySourceSecret),
|
||||
).
|
||||
|
||||
// (a) Watch Namespaces so they're cached and so "namespace appears / starts matching" enqueues reconcile.
|
||||
Watches(
|
||||
&corev1.Namespace{},
|
||||
enqueueOnNamespaceMatch,
|
||||
builder.WithPredicates(namespaceMayMatter),
|
||||
).
|
||||
Complete(r)
|
||||
}
|
||||
|
||||
func isSourceSecret(obj client.Object, r *SecretReplicatorReconciler) bool {
|
||||
if obj == nil {
|
||||
return false
|
||||
}
|
||||
return obj.GetNamespace() == r.SourceNamespace && obj.GetName() == r.SecretName
|
||||
}
|
||||
|
||||
func (r *SecretReplicatorReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
|
||||
logger := log.FromContext(ctx)
|
||||
|
||||
// Ignore requests that don't match our secret name or are for the source namespace
|
||||
if req.Name != r.SecretName || req.Namespace == r.SourceNamespace {
|
||||
return ctrl.Result{}, nil
|
||||
}
|
||||
|
||||
// Verify the target namespace still exists and matches the selector
|
||||
targetNamespace := &corev1.Namespace{}
|
||||
if err := r.Get(ctx, types.NamespacedName{Name: req.Namespace}, targetNamespace); err != nil {
|
||||
if apierrors.IsNotFound(err) {
|
||||
// Namespace doesn't exist, nothing to do
|
||||
return ctrl.Result{}, nil
|
||||
}
|
||||
logger.Error(err, "Failed to get target namespace", "namespace", req.Namespace)
|
||||
return ctrl.Result{}, err
|
||||
}
|
||||
|
||||
// Check if namespace still matches the selector
|
||||
if r.TargetNamespaceSelector != nil && !r.TargetNamespaceSelector.Matches(labels.Set(targetNamespace.Labels)) {
|
||||
// Namespace no longer matches selector, delete the replicated secret if it exists
|
||||
replicatedSecret := &corev1.Secret{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Namespace: req.Namespace,
|
||||
Name: req.Name,
|
||||
},
|
||||
}
|
||||
if err := r.Delete(ctx, replicatedSecret); err != nil && !apierrors.IsNotFound(err) {
|
||||
logger.Error(err, "Failed to delete replicated secret from non-matching namespace",
|
||||
"namespace", req.Namespace, "secret", req.Name)
|
||||
return ctrl.Result{}, err
|
||||
}
|
||||
return ctrl.Result{}, nil
|
||||
}
|
||||
|
||||
// Get the source secret
|
||||
originalSecret := &corev1.Secret{}
|
||||
if err := r.Get(ctx, types.NamespacedName{Namespace: r.SourceNamespace, Name: r.SecretName}, originalSecret); err != nil {
|
||||
if apierrors.IsNotFound(err) {
|
||||
// Source secret doesn't exist, delete the replicated secret if it exists
|
||||
replicatedSecret := &corev1.Secret{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Namespace: req.Namespace,
|
||||
Name: req.Name,
|
||||
},
|
||||
}
|
||||
if err := r.Delete(ctx, replicatedSecret); err != nil && !apierrors.IsNotFound(err) {
|
||||
logger.Error(err, "Failed to delete replicated secret after source secret deletion",
|
||||
"namespace", req.Namespace, "secret", req.Name)
|
||||
return ctrl.Result{}, err
|
||||
}
|
||||
return ctrl.Result{}, nil
|
||||
}
|
||||
logger.Error(err, "Failed to get source secret",
|
||||
"namespace", r.SourceNamespace, "secret", r.SecretName)
|
||||
return ctrl.Result{}, err
|
||||
}
|
||||
|
||||
// Create or update the replicated secret
|
||||
replicatedSecret := &corev1.Secret{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Namespace: req.Namespace,
|
||||
Name: req.Name,
|
||||
},
|
||||
}
|
||||
|
||||
_, err := controllerutil.CreateOrUpdate(ctx, r.Client, replicatedSecret, func() error {
|
||||
// Copy the secret data and type from the source
|
||||
replicatedSecret.Data = make(map[string][]byte)
|
||||
for k, v := range originalSecret.Data {
|
||||
replicatedSecret.Data[k] = v
|
||||
}
|
||||
replicatedSecret.Type = originalSecret.Type
|
||||
|
||||
// Copy labels and annotations from source (if any)
|
||||
if originalSecret.Labels != nil {
|
||||
if replicatedSecret.Labels == nil {
|
||||
replicatedSecret.Labels = make(map[string]string)
|
||||
}
|
||||
for k, v := range originalSecret.Labels {
|
||||
replicatedSecret.Labels[k] = v
|
||||
}
|
||||
}
|
||||
if originalSecret.Annotations != nil {
|
||||
if replicatedSecret.Annotations == nil {
|
||||
replicatedSecret.Annotations = make(map[string]string)
|
||||
}
|
||||
for k, v := range originalSecret.Annotations {
|
||||
replicatedSecret.Annotations[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
logger.Error(err, "Failed to create or update replicated secret",
|
||||
"namespace", req.Namespace, "secret", req.Name)
|
||||
return ctrl.Result{}, err
|
||||
}
|
||||
|
||||
return ctrl.Result{}, nil
|
||||
}
|
||||
378
internal/fluxinstall/install.go
Normal file
378
internal/fluxinstall/install.go
Normal file
@@ -0,0 +1,378 @@
|
||||
/*
|
||||
Copyright 2025 The Cozystack 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 fluxinstall
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
k8syaml "k8s.io/apimachinery/pkg/util/yaml"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
"sigs.k8s.io/controller-runtime/pkg/log"
|
||||
)
|
||||
|
||||
// Install installs Flux components using embedded manifests.
|
||||
// It extracts the manifests and applies them to the cluster.
|
||||
// The namespace is automatically determined from the Namespace object in the manifests.
|
||||
func Install(ctx context.Context, k8sClient client.Client, writeEmbeddedManifests func(string) error) error {
|
||||
logger := log.FromContext(ctx)
|
||||
|
||||
// Create temporary directory for manifests
|
||||
tmpDir, err := os.MkdirTemp("", "flux-install-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create temp directory: %w", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
// Extract embedded manifests (generated by cozypkg)
|
||||
manifestsDir := filepath.Join(tmpDir, "manifests")
|
||||
if err := os.MkdirAll(manifestsDir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create manifests directory: %w", err)
|
||||
}
|
||||
|
||||
if err := writeEmbeddedManifests(manifestsDir); err != nil {
|
||||
return fmt.Errorf("failed to extract embedded manifests: %w", err)
|
||||
}
|
||||
|
||||
// Find the manifest file (should be fluxcd.yaml from cozypkg)
|
||||
manifestPath := filepath.Join(manifestsDir, "fluxcd.yaml")
|
||||
if _, err := os.Stat(manifestPath); err != nil {
|
||||
// Try to find any YAML file if fluxcd.yaml doesn't exist
|
||||
entries, err := os.ReadDir(manifestsDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read manifests directory: %w", err)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if strings.HasSuffix(entry.Name(), ".yaml") {
|
||||
manifestPath = filepath.Join(manifestsDir, entry.Name())
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parse and apply manifests
|
||||
objects, err := parseManifests(manifestPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse manifests: %w", err)
|
||||
}
|
||||
|
||||
if len(objects) == 0 {
|
||||
return fmt.Errorf("no objects found in manifests")
|
||||
}
|
||||
|
||||
// Inject KUBERNETES_SERVICE_HOST and KUBERNETES_SERVICE_PORT if set in operator environment
|
||||
if err := injectKubernetesServiceEnv(objects); err != nil {
|
||||
logger.Info("Failed to inject KUBERNETES_SERVICE_* env vars, continuing anyway", "error", err)
|
||||
}
|
||||
|
||||
// Extract namespace from Namespace object in manifests
|
||||
namespace, err := extractNamespace(objects)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to extract namespace from manifests: %w", err)
|
||||
}
|
||||
|
||||
logger.Info("Installing Flux components", "namespace", namespace)
|
||||
|
||||
// Apply manifests using server-side apply
|
||||
logger.Info("Applying Flux manifests", "count", len(objects), "manifest", manifestPath, "namespace", namespace)
|
||||
if err := applyManifests(ctx, k8sClient, objects); err != nil {
|
||||
return fmt.Errorf("failed to apply manifests: %w", err)
|
||||
}
|
||||
|
||||
logger.Info("Flux installation completed successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseManifests parses YAML manifests into unstructured objects.
|
||||
func parseManifests(manifestPath string) ([]*unstructured.Unstructured, error) {
|
||||
data, err := os.ReadFile(manifestPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read manifest file: %w", err)
|
||||
}
|
||||
|
||||
return readYAMLObjects(bytes.NewReader(data))
|
||||
}
|
||||
|
||||
// readYAMLObjects parses multi-document YAML into unstructured objects.
|
||||
func readYAMLObjects(reader io.Reader) ([]*unstructured.Unstructured, error) {
|
||||
var objects []*unstructured.Unstructured
|
||||
yamlReader := k8syaml.NewYAMLReader(bufio.NewReader(reader))
|
||||
|
||||
for {
|
||||
doc, err := yamlReader.Read()
|
||||
if err != nil {
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
return nil, fmt.Errorf("failed to read YAML document: %w", err)
|
||||
}
|
||||
|
||||
// Skip empty documents
|
||||
if len(bytes.TrimSpace(doc)) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
obj := &unstructured.Unstructured{}
|
||||
decoder := k8syaml.NewYAMLOrJSONDecoder(bytes.NewReader(doc), len(doc))
|
||||
if err := decoder.Decode(obj); err != nil {
|
||||
// Skip documents that can't be decoded (might be comments or empty)
|
||||
if err == io.EOF {
|
||||
continue
|
||||
}
|
||||
return nil, fmt.Errorf("failed to decode YAML document: %w", err)
|
||||
}
|
||||
|
||||
// Skip empty objects (no kind)
|
||||
if obj.GetKind() == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
objects = append(objects, obj)
|
||||
}
|
||||
|
||||
return objects, nil
|
||||
}
|
||||
|
||||
// applyManifests applies Kubernetes objects using server-side apply.
|
||||
func applyManifests(ctx context.Context, k8sClient client.Client, objects []*unstructured.Unstructured) error {
|
||||
logger := log.FromContext(ctx)
|
||||
|
||||
// Separate CRDs and namespaces from other resources
|
||||
var stageOne []*unstructured.Unstructured // CRDs and Namespaces
|
||||
var stageTwo []*unstructured.Unstructured // Everything else
|
||||
|
||||
for _, obj := range objects {
|
||||
if isClusterDefinition(obj) {
|
||||
stageOne = append(stageOne, obj)
|
||||
} else {
|
||||
stageTwo = append(stageTwo, obj)
|
||||
}
|
||||
}
|
||||
|
||||
// Apply stage one (CRDs and Namespaces) first
|
||||
if len(stageOne) > 0 {
|
||||
logger.Info("Applying cluster definitions", "count", len(stageOne))
|
||||
if err := applyObjects(ctx, k8sClient, stageOne); err != nil {
|
||||
return fmt.Errorf("failed to apply cluster definitions: %w", err)
|
||||
}
|
||||
|
||||
// Wait a bit for CRDs to be registered
|
||||
time.Sleep(2 * time.Second)
|
||||
}
|
||||
|
||||
// Apply stage two (everything else)
|
||||
if len(stageTwo) > 0 {
|
||||
logger.Info("Applying resources", "count", len(stageTwo))
|
||||
if err := applyObjects(ctx, k8sClient, stageTwo); err != nil {
|
||||
return fmt.Errorf("failed to apply resources: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// applyObjects applies a list of objects using server-side apply.
|
||||
func applyObjects(ctx context.Context, k8sClient client.Client, objects []*unstructured.Unstructured) error {
|
||||
for _, obj := range objects {
|
||||
// Use server-side apply with force ownership and field manager
|
||||
// FieldManager is required for apply patch operations
|
||||
patchOptions := &client.PatchOptions{
|
||||
FieldManager: "cozystack-operator",
|
||||
Force: func() *bool { b := true; return &b }(),
|
||||
}
|
||||
|
||||
if err := k8sClient.Patch(ctx, obj, client.Apply, patchOptions); err != nil {
|
||||
return fmt.Errorf("failed to apply object %s/%s: %w", obj.GetKind(), obj.GetName(), err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
// extractNamespace extracts the namespace name from the Namespace object in the manifests.
|
||||
func extractNamespace(objects []*unstructured.Unstructured) (string, error) {
|
||||
for _, obj := range objects {
|
||||
if obj.GetKind() == "Namespace" {
|
||||
namespace := obj.GetName()
|
||||
if namespace == "" {
|
||||
return "", fmt.Errorf("Namespace object has no name")
|
||||
}
|
||||
return namespace, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("no Namespace object found in manifests")
|
||||
}
|
||||
|
||||
// isClusterDefinition checks if an object is a CRD or Namespace.
|
||||
func isClusterDefinition(obj *unstructured.Unstructured) bool {
|
||||
kind := obj.GetKind()
|
||||
return kind == "CustomResourceDefinition" || kind == "Namespace"
|
||||
}
|
||||
|
||||
// injectKubernetesServiceEnv injects KUBERNETES_SERVICE_HOST and KUBERNETES_SERVICE_PORT
|
||||
// environment variables into all containers of Deployment, StatefulSet, and DaemonSet objects
|
||||
// if these variables are set in the operator's environment.
|
||||
// Errors are logged but do not stop processing of other objects.
|
||||
func injectKubernetesServiceEnv(objects []*unstructured.Unstructured) error {
|
||||
kubernetesHost := os.Getenv("KUBERNETES_SERVICE_HOST")
|
||||
kubernetesPort := os.Getenv("KUBERNETES_SERVICE_PORT")
|
||||
|
||||
// If neither variable is set, nothing to do
|
||||
if kubernetesHost == "" && kubernetesPort == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
var firstErr error
|
||||
for _, obj := range objects {
|
||||
kind := obj.GetKind()
|
||||
if kind != "Deployment" && kind != "StatefulSet" && kind != "DaemonSet" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Navigate to spec.template.spec.containers
|
||||
spec, found, err := unstructured.NestedMap(obj.Object, "spec", "template", "spec")
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
if firstErr == nil {
|
||||
firstErr = fmt.Errorf("failed to get spec for %s/%s: %w", kind, obj.GetName(), err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Update containers
|
||||
containers, found, err := unstructured.NestedSlice(spec, "containers")
|
||||
if err != nil {
|
||||
if firstErr == nil {
|
||||
firstErr = fmt.Errorf("failed to get containers for %s/%s: %w", kind, obj.GetName(), err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if found {
|
||||
containers = updateContainersEnv(containers, kubernetesHost, kubernetesPort)
|
||||
if err := unstructured.SetNestedSlice(spec, containers, "containers"); err != nil {
|
||||
if firstErr == nil {
|
||||
firstErr = fmt.Errorf("failed to set containers for %s/%s: %w", kind, obj.GetName(), err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Update initContainers
|
||||
initContainers, found, err := unstructured.NestedSlice(spec, "initContainers")
|
||||
if err != nil {
|
||||
if firstErr == nil {
|
||||
firstErr = fmt.Errorf("failed to get initContainers for %s/%s: %w", kind, obj.GetName(), err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if found {
|
||||
initContainers = updateContainersEnv(initContainers, kubernetesHost, kubernetesPort)
|
||||
if err := unstructured.SetNestedSlice(spec, initContainers, "initContainers"); err != nil {
|
||||
if firstErr == nil {
|
||||
firstErr = fmt.Errorf("failed to set initContainers for %s/%s: %w", kind, obj.GetName(), err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Update spec in the object
|
||||
if err := unstructured.SetNestedMap(obj.Object, spec, "spec", "template", "spec"); err != nil {
|
||||
if firstErr == nil {
|
||||
firstErr = fmt.Errorf("failed to update spec for %s/%s: %w", kind, obj.GetName(), err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return firstErr
|
||||
}
|
||||
|
||||
// updateContainersEnv updates environment variables for a slice of containers.
|
||||
func updateContainersEnv(containers []interface{}, kubernetesHost, kubernetesPort string) []interface{} {
|
||||
for i, container := range containers {
|
||||
containerMap, ok := container.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
env, found, err := unstructured.NestedSlice(containerMap, "env")
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if !found {
|
||||
env = []interface{}{}
|
||||
}
|
||||
|
||||
// Update or add KUBERNETES_SERVICE_HOST
|
||||
if kubernetesHost != "" {
|
||||
env = setEnvVar(env, "KUBERNETES_SERVICE_HOST", kubernetesHost)
|
||||
}
|
||||
|
||||
// Update or add KUBERNETES_SERVICE_PORT
|
||||
if kubernetesPort != "" {
|
||||
env = setEnvVar(env, "KUBERNETES_SERVICE_PORT", kubernetesPort)
|
||||
}
|
||||
|
||||
// Update the container's env
|
||||
if err := unstructured.SetNestedSlice(containerMap, env, "env"); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Update the container in the slice
|
||||
containers[i] = containerMap
|
||||
}
|
||||
|
||||
return containers
|
||||
}
|
||||
|
||||
// setEnvVar updates or adds an environment variable in the env slice.
|
||||
func setEnvVar(env []interface{}, name, value string) []interface{} {
|
||||
// Check if variable already exists
|
||||
for i, envVar := range env {
|
||||
envVarMap, ok := envVar.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
if envVarMap["name"] == name {
|
||||
// Update existing variable
|
||||
envVarMap["value"] = value
|
||||
env[i] = envVarMap
|
||||
return env
|
||||
}
|
||||
}
|
||||
|
||||
// Add new variable
|
||||
env = append(env, map[string]interface{}{
|
||||
"name": name,
|
||||
"value": value,
|
||||
})
|
||||
|
||||
return env
|
||||
}
|
||||
|
||||
51
internal/fluxinstall/manifests.embed.go
Normal file
51
internal/fluxinstall/manifests.embed.go
Normal file
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
Copyright 2025 The Cozystack 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 fluxinstall
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path"
|
||||
)
|
||||
|
||||
//go:embed manifests/*.yaml
|
||||
var embeddedFluxManifests embed.FS
|
||||
|
||||
// WriteEmbeddedManifests extracts embedded Flux manifests to a temporary directory.
|
||||
func WriteEmbeddedManifests(dir string) error {
|
||||
manifests, err := fs.ReadDir(embeddedFluxManifests, "manifests")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read embedded manifests: %w", err)
|
||||
}
|
||||
|
||||
for _, manifest := range manifests {
|
||||
data, err := fs.ReadFile(embeddedFluxManifests, path.Join("manifests", manifest.Name()))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read file %s: %w", manifest.Name(), err)
|
||||
}
|
||||
|
||||
outputPath := path.Join(dir, manifest.Name())
|
||||
if err := os.WriteFile(outputPath, data, 0666); err != nil {
|
||||
return fmt.Errorf("failed to write file %s: %w", outputPath, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
11951
internal/fluxinstall/manifests/fluxcd.yaml
Normal file
11951
internal/fluxinstall/manifests/fluxcd.yaml
Normal file
File diff suppressed because it is too large
Load Diff
@@ -2,49 +2,72 @@ package lineagecontrollerwebhook
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
cozyv1alpha1 "github.com/cozystack/cozystack/api/v1alpha1"
|
||||
helmv2 "github.com/fluxcd/helm-controller/api/v2"
|
||||
)
|
||||
|
||||
type chartRef struct {
|
||||
repo string
|
||||
chart string
|
||||
}
|
||||
|
||||
type appRef struct {
|
||||
group string
|
||||
kind string
|
||||
}
|
||||
|
||||
type runtimeConfig struct {
|
||||
chartAppMap map[chartRef]*cozyv1alpha1.CozystackResourceDefinition
|
||||
appCRDMap map[appRef]*cozyv1alpha1.CozystackResourceDefinition
|
||||
appCRDMap map[appRef]*cozyv1alpha1.CozystackResourceDefinition
|
||||
}
|
||||
|
||||
func (l *LineageControllerWebhook) initConfig() {
|
||||
l.initOnce.Do(func() {
|
||||
if l.config.Load() == nil {
|
||||
l.config.Store(&runtimeConfig{
|
||||
chartAppMap: make(map[chartRef]*cozyv1alpha1.CozystackResourceDefinition),
|
||||
appCRDMap: make(map[appRef]*cozyv1alpha1.CozystackResourceDefinition),
|
||||
appCRDMap: make(map[appRef]*cozyv1alpha1.CozystackResourceDefinition),
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (l *LineageControllerWebhook) Map(hr *helmv2.HelmRelease) (string, string, string, error) {
|
||||
cfg, ok := l.config.Load().(*runtimeConfig)
|
||||
// getApplicationLabel safely extracts an application label from HelmRelease
|
||||
func getApplicationLabel(hr *helmv2.HelmRelease, key string) (string, error) {
|
||||
if hr.Labels == nil {
|
||||
return "", fmt.Errorf("cannot map helm release %s/%s to dynamic app: labels are nil", hr.Namespace, hr.Name)
|
||||
}
|
||||
val, ok := hr.Labels[key]
|
||||
if !ok {
|
||||
return "", "", "", fmt.Errorf("failed to load chart-app mapping from config")
|
||||
return "", fmt.Errorf("cannot map helm release %s/%s to dynamic app: missing %s label", hr.Namespace, hr.Name, key)
|
||||
}
|
||||
if hr.Spec.Chart == nil {
|
||||
return "", "", "", fmt.Errorf("cannot map helm release %s/%s to dynamic app", hr.Namespace, hr.Name)
|
||||
}
|
||||
s := hr.Spec.Chart.Spec
|
||||
val, ok := cfg.chartAppMap[chartRef{s.SourceRef.Name, s.Chart}]
|
||||
if !ok {
|
||||
return "", "", "", fmt.Errorf("cannot map helm release %s/%s to dynamic app", hr.Namespace, hr.Name)
|
||||
}
|
||||
return "apps.cozystack.io/v1alpha1", val.Spec.Application.Kind, val.Spec.Release.Prefix, nil
|
||||
return val, nil
|
||||
}
|
||||
|
||||
func (l *LineageControllerWebhook) Map(hr *helmv2.HelmRelease) (string, string, string, error) {
|
||||
// Extract application metadata from labels
|
||||
appKind, err := getApplicationLabel(hr, "apps.cozystack.io/application.kind")
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
|
||||
appGroup, err := getApplicationLabel(hr, "apps.cozystack.io/application.group")
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
|
||||
appName, err := getApplicationLabel(hr, "apps.cozystack.io/application.name")
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
|
||||
// Construct API version from group
|
||||
apiVersion := fmt.Sprintf("%s/v1alpha1", appGroup)
|
||||
|
||||
// Extract prefix from HelmRelease name by removing the application name
|
||||
// HelmRelease name format: <prefix><application-name>
|
||||
prefix := strings.TrimSuffix(hr.Name, appName)
|
||||
|
||||
// Validate the derived prefix
|
||||
// This ensures correctness when appName appears multiple times in hr.Name
|
||||
if prefix+appName != hr.Name {
|
||||
return "", "", "", fmt.Errorf("cannot derive prefix from helm release %s/%s: name does not end with application name %s", hr.Namespace, hr.Name, appName)
|
||||
}
|
||||
|
||||
return apiVersion, appKind, prefix, nil
|
||||
}
|
||||
|
||||
@@ -24,25 +24,15 @@ func (c *LineageControllerWebhook) Reconcile(ctx context.Context, req ctrl.Reque
|
||||
return ctrl.Result{}, err
|
||||
}
|
||||
cfg := &runtimeConfig{
|
||||
chartAppMap: make(map[chartRef]*cozyv1alpha1.CozystackResourceDefinition),
|
||||
appCRDMap: make(map[appRef]*cozyv1alpha1.CozystackResourceDefinition),
|
||||
appCRDMap: make(map[appRef]*cozyv1alpha1.CozystackResourceDefinition),
|
||||
}
|
||||
for _, crd := range crds.Items {
|
||||
chRef := chartRef{
|
||||
crd.Spec.Release.Chart.SourceRef.Name,
|
||||
crd.Spec.Release.Chart.Name,
|
||||
}
|
||||
appRef := appRef{
|
||||
"apps.cozystack.io",
|
||||
crd.Spec.Application.Kind,
|
||||
}
|
||||
|
||||
newRef := crd
|
||||
if _, exists := cfg.chartAppMap[chRef]; exists {
|
||||
l.Info("duplicate chart mapping detected; ignoring subsequent entry", "key", chRef)
|
||||
} else {
|
||||
cfg.chartAppMap[chRef] = &newRef
|
||||
}
|
||||
if _, exists := cfg.appCRDMap[appRef]; exists {
|
||||
l.Info("duplicate app mapping detected; ignoring subsequent entry", "key", appRef)
|
||||
} else {
|
||||
|
||||
963
internal/operator/package_reconciler.go
Normal file
963
internal/operator/package_reconciler.go
Normal file
@@ -0,0 +1,963 @@
|
||||
/*
|
||||
Copyright 2025 The Cozystack 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 operator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
cozyv1alpha1 "github.com/cozystack/cozystack/api/v1alpha1"
|
||||
helmv2 "github.com/fluxcd/helm-controller/api/v2"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
"k8s.io/apimachinery/pkg/api/meta"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
ctrl "sigs.k8s.io/controller-runtime"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client/apiutil"
|
||||
"sigs.k8s.io/controller-runtime/pkg/handler"
|
||||
"sigs.k8s.io/controller-runtime/pkg/log"
|
||||
"sigs.k8s.io/controller-runtime/pkg/reconcile"
|
||||
)
|
||||
|
||||
const (
|
||||
// AnnotationSkipCozystackValues disables injection of cozystack-values secret into HelmRelease
|
||||
// This annotation should be placed on PackageSource
|
||||
AnnotationSkipCozystackValues = "operator.cozystack.io/skip-cozystack-values"
|
||||
// SecretCozystackValues is the name of the secret containing cluster and namespace configuration
|
||||
SecretCozystackValues = "cozystack-values"
|
||||
)
|
||||
|
||||
// PackageReconciler reconciles Package resources
|
||||
type PackageReconciler struct {
|
||||
client.Client
|
||||
Scheme *runtime.Scheme
|
||||
}
|
||||
|
||||
// +kubebuilder:rbac:groups=cozystack.io,resources=packages,verbs=get;list;watch;create;update;patch;delete
|
||||
// +kubebuilder:rbac:groups=cozystack.io,resources=packages/status,verbs=get;update;patch
|
||||
// +kubebuilder:rbac:groups=cozystack.io,resources=packagesources,verbs=get;list;watch
|
||||
// +kubebuilder:rbac:groups=helm.toolkit.fluxcd.io,resources=helmreleases,verbs=get;list;watch;create;update;patch;delete
|
||||
// +kubebuilder:rbac:groups=core,resources=namespaces,verbs=get;list;watch;create;update;patch
|
||||
|
||||
// Reconcile is part of the main kubernetes reconciliation loop
|
||||
func (r *PackageReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
|
||||
logger := log.FromContext(ctx)
|
||||
|
||||
pkg := &cozyv1alpha1.Package{}
|
||||
if err := r.Get(ctx, req.NamespacedName, pkg); err != nil {
|
||||
if apierrors.IsNotFound(err) {
|
||||
// Resource not found, return (ownerReference will handle cleanup)
|
||||
return ctrl.Result{}, nil
|
||||
}
|
||||
return ctrl.Result{}, err
|
||||
}
|
||||
|
||||
// Get PackageSource with the same name
|
||||
packageSource := &cozyv1alpha1.PackageSource{}
|
||||
if err := r.Get(ctx, types.NamespacedName{Name: pkg.Name}, packageSource); err != nil {
|
||||
if apierrors.IsNotFound(err) {
|
||||
meta.SetStatusCondition(&pkg.Status.Conditions, metav1.Condition{
|
||||
Type: "Ready",
|
||||
Status: metav1.ConditionFalse,
|
||||
Reason: "PackageSourceNotFound",
|
||||
Message: fmt.Sprintf("PackageSource %s not found", pkg.Name),
|
||||
})
|
||||
if err := r.Status().Update(ctx, pkg); err != nil {
|
||||
return ctrl.Result{}, err
|
||||
}
|
||||
return ctrl.Result{}, nil
|
||||
}
|
||||
return ctrl.Result{}, err
|
||||
}
|
||||
|
||||
// Determine variant (default to "default" if not specified)
|
||||
variantName := pkg.Spec.Variant
|
||||
if variantName == "" {
|
||||
variantName = "default"
|
||||
}
|
||||
|
||||
// Find the variant in PackageSource
|
||||
var variant *cozyv1alpha1.Variant
|
||||
for i := range packageSource.Spec.Variants {
|
||||
if packageSource.Spec.Variants[i].Name == variantName {
|
||||
variant = &packageSource.Spec.Variants[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if variant == nil {
|
||||
meta.SetStatusCondition(&pkg.Status.Conditions, metav1.Condition{
|
||||
Type: "Ready",
|
||||
Status: metav1.ConditionFalse,
|
||||
Reason: "VariantNotFound",
|
||||
Message: fmt.Sprintf("Variant %s not found in PackageSource %s", variantName, pkg.Name),
|
||||
})
|
||||
if err := r.Status().Update(ctx, pkg); err != nil {
|
||||
return ctrl.Result{}, err
|
||||
}
|
||||
return ctrl.Result{}, nil
|
||||
}
|
||||
|
||||
// Reconcile namespaces from components
|
||||
if err := r.reconcileNamespaces(ctx, pkg, variant); err != nil {
|
||||
logger.Error(err, "failed to reconcile namespaces")
|
||||
return ctrl.Result{}, err
|
||||
}
|
||||
|
||||
// Update dependencies status
|
||||
if err := r.updateDependenciesStatus(ctx, pkg, variant); err != nil {
|
||||
logger.Error(err, "failed to update dependencies status")
|
||||
// Don't return error, continue with reconciliation
|
||||
}
|
||||
|
||||
// Validate variant dependencies before creating HelmReleases
|
||||
// Check if all dependencies are ready based on status
|
||||
if !r.areDependenciesReady(pkg, variant) {
|
||||
logger.Info("variant dependencies not ready, skipping HelmRelease creation", "package", pkg.Name)
|
||||
meta.SetStatusCondition(&pkg.Status.Conditions, metav1.Condition{
|
||||
Type: "Ready",
|
||||
Status: metav1.ConditionFalse,
|
||||
Reason: "DependenciesNotReady",
|
||||
Message: "One or more dependencies are not ready",
|
||||
})
|
||||
if err := r.Status().Update(ctx, pkg); err != nil {
|
||||
return ctrl.Result{}, err
|
||||
}
|
||||
// Return success to avoid requeue, but don't create HelmReleases
|
||||
return ctrl.Result{}, nil
|
||||
}
|
||||
|
||||
// Create HelmReleases for components with Install section
|
||||
helmReleaseCount := 0
|
||||
for _, component := range variant.Components {
|
||||
// Skip components without Install section
|
||||
if component.Install == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if component is disabled via Package spec
|
||||
if pkgComponent, ok := pkg.Spec.Components[component.Name]; ok {
|
||||
if pkgComponent.Enabled != nil && !*pkgComponent.Enabled {
|
||||
logger.V(1).Info("skipping disabled component", "package", pkg.Name, "component", component.Name)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Build artifact name: <packagesource>-<variant>-<componentname> (with dots replaced by dashes)
|
||||
artifactName := fmt.Sprintf("%s-%s-%s",
|
||||
strings.ReplaceAll(packageSource.Name, ".", "-"),
|
||||
strings.ReplaceAll(variantName, ".", "-"),
|
||||
strings.ReplaceAll(component.Name, ".", "-"))
|
||||
|
||||
// Namespace must be set
|
||||
namespace := component.Install.Namespace
|
||||
if namespace == "" {
|
||||
logger.Error(fmt.Errorf("component %s has empty namespace in Install section", component.Name), "namespace validation failed")
|
||||
meta.SetStatusCondition(&pkg.Status.Conditions, metav1.Condition{
|
||||
Type: "Ready",
|
||||
Status: metav1.ConditionFalse,
|
||||
Reason: "InvalidConfiguration",
|
||||
Message: fmt.Sprintf("Component %s has empty namespace in Install section", component.Name),
|
||||
})
|
||||
if err := r.Status().Update(ctx, pkg); err != nil {
|
||||
return ctrl.Result{}, err
|
||||
}
|
||||
return ctrl.Result{}, fmt.Errorf("component %s has empty namespace in Install section", component.Name)
|
||||
}
|
||||
|
||||
// Determine release name (from Install or use component name)
|
||||
releaseName := component.Install.ReleaseName
|
||||
if releaseName == "" {
|
||||
releaseName = component.Name
|
||||
}
|
||||
|
||||
// Build labels
|
||||
labels := make(map[string]string)
|
||||
labels["cozystack.io/package"] = pkg.Name
|
||||
if component.Install.Privileged {
|
||||
labels["cozystack.io/privileged"] = "true"
|
||||
}
|
||||
|
||||
// Create HelmRelease
|
||||
hr := &helmv2.HelmRelease{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: releaseName,
|
||||
Namespace: namespace,
|
||||
Labels: labels,
|
||||
},
|
||||
Spec: helmv2.HelmReleaseSpec{
|
||||
Interval: metav1.Duration{Duration: 5 * 60 * 1000000000}, // 5m
|
||||
ChartRef: &helmv2.CrossNamespaceSourceReference{
|
||||
Kind: "ExternalArtifact",
|
||||
Name: artifactName,
|
||||
Namespace: "cozy-system",
|
||||
},
|
||||
Install: &helmv2.Install{
|
||||
Remediation: &helmv2.InstallRemediation{
|
||||
Retries: -1,
|
||||
},
|
||||
},
|
||||
Upgrade: &helmv2.Upgrade{
|
||||
Remediation: &helmv2.UpgradeRemediation{
|
||||
Retries: -1,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Add valuesFrom for cozystack-values secret unless disabled by annotation on PackageSource
|
||||
if packageSource.GetAnnotations()[AnnotationSkipCozystackValues] != "true" {
|
||||
hr.Spec.ValuesFrom = []helmv2.ValuesReference{
|
||||
{
|
||||
Kind: "Secret",
|
||||
Name: SecretCozystackValues,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Set ownerReference
|
||||
gvk, err := apiutil.GVKForObject(pkg, r.Scheme)
|
||||
if err != nil {
|
||||
logger.Error(err, "failed to get GVK for Package")
|
||||
meta.SetStatusCondition(&pkg.Status.Conditions, metav1.Condition{
|
||||
Type: "Ready",
|
||||
Status: metav1.ConditionFalse,
|
||||
Reason: "InternalError",
|
||||
Message: fmt.Sprintf("Failed to get GVK for Package: %v", err),
|
||||
})
|
||||
if err := r.Status().Update(ctx, pkg); err != nil {
|
||||
return ctrl.Result{}, err
|
||||
}
|
||||
return ctrl.Result{}, fmt.Errorf("failed to get GVK for Package: %w", err)
|
||||
}
|
||||
hr.OwnerReferences = []metav1.OwnerReference{
|
||||
{
|
||||
APIVersion: gvk.GroupVersion().String(),
|
||||
Kind: gvk.Kind,
|
||||
Name: pkg.Name,
|
||||
UID: pkg.UID,
|
||||
Controller: func() *bool { b := true; return &b }(),
|
||||
},
|
||||
}
|
||||
|
||||
// Merge values from Package spec if provided
|
||||
if pkgComponent, ok := pkg.Spec.Components[component.Name]; ok && pkgComponent.Values != nil {
|
||||
hr.Spec.Values = pkgComponent.Values
|
||||
}
|
||||
|
||||
// Build DependsOn from component Install and variant DependsOn
|
||||
dependsOn, err := r.buildDependsOn(ctx, pkg, packageSource, variant, &component)
|
||||
if err != nil {
|
||||
logger.Error(err, "failed to build DependsOn", "component", component.Name)
|
||||
meta.SetStatusCondition(&pkg.Status.Conditions, metav1.Condition{
|
||||
Type: "Ready",
|
||||
Status: metav1.ConditionFalse,
|
||||
Reason: "DependsOnFailed",
|
||||
Message: fmt.Sprintf("Failed to build DependsOn for component %s: %v", component.Name, err),
|
||||
})
|
||||
if err := r.Status().Update(ctx, pkg); err != nil {
|
||||
return ctrl.Result{}, err
|
||||
}
|
||||
// Return nil to stop reconciliation, error is recorded in status
|
||||
return ctrl.Result{}, nil
|
||||
}
|
||||
if len(dependsOn) > 0 {
|
||||
hr.Spec.DependsOn = dependsOn
|
||||
}
|
||||
|
||||
// Set valuesFiles annotation
|
||||
if len(component.ValuesFiles) > 0 {
|
||||
if hr.Annotations == nil {
|
||||
hr.Annotations = make(map[string]string)
|
||||
}
|
||||
hr.Annotations["cozyhr.cozystack.io/values-files"] = strings.Join(component.ValuesFiles, ",")
|
||||
}
|
||||
|
||||
if err := r.createOrUpdateHelmRelease(ctx, hr); err != nil {
|
||||
logger.Error(err, "failed to reconcile HelmRelease", "name", releaseName, "namespace", namespace)
|
||||
meta.SetStatusCondition(&pkg.Status.Conditions, metav1.Condition{
|
||||
Type: "Ready",
|
||||
Status: metav1.ConditionFalse,
|
||||
Reason: "HelmReleaseFailed",
|
||||
Message: fmt.Sprintf("Failed to create HelmRelease %s: %v", releaseName, err),
|
||||
})
|
||||
if err := r.Status().Update(ctx, pkg); err != nil {
|
||||
return ctrl.Result{}, err
|
||||
}
|
||||
return ctrl.Result{}, err
|
||||
}
|
||||
|
||||
helmReleaseCount++
|
||||
logger.Info("reconciled HelmRelease", "package", pkg.Name, "component", component.Name, "releaseName", releaseName, "namespace", namespace)
|
||||
}
|
||||
|
||||
// Cleanup orphaned HelmReleases
|
||||
if err := r.cleanupOrphanedHelmReleases(ctx, pkg, variant); err != nil {
|
||||
logger.Error(err, "failed to cleanup orphaned HelmReleases")
|
||||
// Don't return error, continue with status update
|
||||
}
|
||||
|
||||
// Update status with success message
|
||||
message := fmt.Sprintf("reconciliation succeeded, generated %d helmrelease(s)", helmReleaseCount)
|
||||
meta.SetStatusCondition(&pkg.Status.Conditions, metav1.Condition{
|
||||
Type: "Ready",
|
||||
Status: metav1.ConditionTrue,
|
||||
Reason: "ReconciliationSucceeded",
|
||||
Message: message,
|
||||
})
|
||||
|
||||
if err := r.Status().Update(ctx, pkg); err != nil {
|
||||
return ctrl.Result{}, err
|
||||
}
|
||||
|
||||
logger.Info("reconciled Package", "name", pkg.Name, "helmReleaseCount", helmReleaseCount)
|
||||
|
||||
// Update dependencies status for Packages that depend on this Package
|
||||
// This ensures they get re-enqueued when their dependency becomes ready
|
||||
if err := r.updateDependentPackagesDependencies(ctx, pkg.Name); err != nil {
|
||||
logger.V(1).Error(err, "failed to update dependent packages dependencies", "package", pkg.Name)
|
||||
// Don't return error, this is best-effort
|
||||
}
|
||||
|
||||
// Dependent Packages will be automatically enqueued by the watch handler
|
||||
// when this Package's status is updated (see SetupWithManager watch handler)
|
||||
|
||||
return ctrl.Result{}, nil
|
||||
}
|
||||
|
||||
// createOrUpdateHelmRelease creates or updates a HelmRelease
|
||||
func (r *PackageReconciler) createOrUpdateHelmRelease(ctx context.Context, hr *helmv2.HelmRelease) error {
|
||||
existing := &helmv2.HelmRelease{}
|
||||
key := types.NamespacedName{
|
||||
Name: hr.Name,
|
||||
Namespace: hr.Namespace,
|
||||
}
|
||||
|
||||
err := r.Get(ctx, key, existing)
|
||||
if apierrors.IsNotFound(err) {
|
||||
return r.Create(ctx, hr)
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Preserve resource version
|
||||
hr.SetResourceVersion(existing.GetResourceVersion())
|
||||
|
||||
// Merge labels
|
||||
labels := hr.GetLabels()
|
||||
if labels == nil {
|
||||
labels = make(map[string]string)
|
||||
}
|
||||
for k, v := range existing.GetLabels() {
|
||||
if _, ok := labels[k]; !ok {
|
||||
labels[k] = v
|
||||
}
|
||||
}
|
||||
hr.SetLabels(labels)
|
||||
|
||||
// Merge annotations
|
||||
annotations := hr.GetAnnotations()
|
||||
if annotations == nil {
|
||||
annotations = make(map[string]string)
|
||||
}
|
||||
for k, v := range existing.GetAnnotations() {
|
||||
if _, ok := annotations[k]; !ok {
|
||||
annotations[k] = v
|
||||
}
|
||||
}
|
||||
hr.SetAnnotations(annotations)
|
||||
|
||||
// Update Spec
|
||||
existing.Spec = hr.Spec
|
||||
existing.SetLabels(hr.GetLabels())
|
||||
existing.SetAnnotations(hr.GetAnnotations())
|
||||
existing.SetOwnerReferences(hr.GetOwnerReferences())
|
||||
|
||||
return r.Update(ctx, existing)
|
||||
}
|
||||
|
||||
// getVariantForPackage retrieves the Variant for a given Package
|
||||
// Returns the Variant and an error if not found
|
||||
// If c is nil, uses the reconciler's client
|
||||
func (r *PackageReconciler) getVariantForPackage(ctx context.Context, pkg *cozyv1alpha1.Package, c client.Client) (*cozyv1alpha1.Variant, error) {
|
||||
// Use provided client or fall back to reconciler's client
|
||||
cl := c
|
||||
if cl == nil {
|
||||
cl = r.Client
|
||||
}
|
||||
|
||||
// Determine variant name (default to "default" if not specified)
|
||||
variantName := pkg.Spec.Variant
|
||||
if variantName == "" {
|
||||
variantName = "default"
|
||||
}
|
||||
|
||||
// Get the PackageSource
|
||||
packageSource := &cozyv1alpha1.PackageSource{}
|
||||
if err := cl.Get(ctx, types.NamespacedName{Name: pkg.Name}, packageSource); err != nil {
|
||||
if apierrors.IsNotFound(err) {
|
||||
return nil, fmt.Errorf("PackageSource %s not found", pkg.Name)
|
||||
}
|
||||
return nil, fmt.Errorf("failed to get PackageSource %s: %w", pkg.Name, err)
|
||||
}
|
||||
|
||||
// Find the variant in PackageSource
|
||||
var variant *cozyv1alpha1.Variant
|
||||
for i := range packageSource.Spec.Variants {
|
||||
if packageSource.Spec.Variants[i].Name == variantName {
|
||||
variant = &packageSource.Spec.Variants[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if variant == nil {
|
||||
return nil, fmt.Errorf("variant %s not found in PackageSource %s", variantName, pkg.Name)
|
||||
}
|
||||
|
||||
return variant, nil
|
||||
}
|
||||
|
||||
// buildDependsOn builds DependsOn list for a component
|
||||
// Includes:
|
||||
// 1. Dependencies from component.Install.DependsOn (with namespace from referenced component)
|
||||
// 2. Dependencies from variant.DependsOn (all components with Install from referenced Package)
|
||||
func (r *PackageReconciler) buildDependsOn(ctx context.Context, pkg *cozyv1alpha1.Package, packageSource *cozyv1alpha1.PackageSource, variant *cozyv1alpha1.Variant, component *cozyv1alpha1.Component) ([]helmv2.DependencyReference, error) {
|
||||
logger := log.FromContext(ctx)
|
||||
dependsOn := []helmv2.DependencyReference{}
|
||||
|
||||
// Build map of component names to their release names and namespaces in current variant
|
||||
componentMap := make(map[string]struct {
|
||||
releaseName string
|
||||
namespace string
|
||||
})
|
||||
for _, comp := range variant.Components {
|
||||
if comp.Install == nil {
|
||||
continue
|
||||
}
|
||||
compNamespace := comp.Install.Namespace
|
||||
if compNamespace == "" {
|
||||
return nil, fmt.Errorf("component %s has empty namespace in Install section", comp.Name)
|
||||
}
|
||||
compReleaseName := comp.Install.ReleaseName
|
||||
if compReleaseName == "" {
|
||||
compReleaseName = comp.Name
|
||||
}
|
||||
componentMap[comp.Name] = struct {
|
||||
releaseName string
|
||||
namespace string
|
||||
}{
|
||||
releaseName: compReleaseName,
|
||||
namespace: compNamespace,
|
||||
}
|
||||
}
|
||||
|
||||
// Add dependencies from component.Install.DependsOn
|
||||
if len(component.Install.DependsOn) > 0 {
|
||||
for _, depName := range component.Install.DependsOn {
|
||||
depComp, ok := componentMap[depName]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("component %s not found in variant for dependency %s", depName, component.Name)
|
||||
}
|
||||
dependsOn = append(dependsOn, helmv2.DependencyReference{
|
||||
Name: depComp.releaseName,
|
||||
Namespace: depComp.namespace,
|
||||
})
|
||||
logger.V(1).Info("added component dependency", "component", component.Name, "dependsOn", depName, "releaseName", depComp.releaseName, "namespace", depComp.namespace)
|
||||
}
|
||||
}
|
||||
|
||||
// Add dependencies from variant.DependsOn
|
||||
if len(variant.DependsOn) > 0 {
|
||||
for _, depPackageName := range variant.DependsOn {
|
||||
// Check if dependency is in IgnoreDependencies
|
||||
ignore := false
|
||||
for _, ignoreDep := range pkg.Spec.IgnoreDependencies {
|
||||
if ignoreDep == depPackageName {
|
||||
ignore = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if ignore {
|
||||
logger.V(1).Info("ignoring dependency", "package", pkg.Name, "dependency", depPackageName)
|
||||
continue
|
||||
}
|
||||
|
||||
// Get the Package
|
||||
depPackage := &cozyv1alpha1.Package{}
|
||||
if err := r.Get(ctx, types.NamespacedName{Name: depPackageName}, depPackage); err != nil {
|
||||
if apierrors.IsNotFound(err) {
|
||||
return nil, fmt.Errorf("dependent Package %s not found", depPackageName)
|
||||
}
|
||||
return nil, fmt.Errorf("failed to get dependent Package %s: %w", depPackageName, err)
|
||||
}
|
||||
|
||||
// Get the variant from dependent Package
|
||||
depVariant, err := r.getVariantForPackage(ctx, depPackage, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get variant for dependent Package %s: %w", depPackageName, err)
|
||||
}
|
||||
|
||||
// Add all components with Install from dependent variant
|
||||
for _, depComp := range depVariant.Components {
|
||||
if depComp.Install == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if component is disabled in dependent Package
|
||||
if depPkgComponent, ok := depPackage.Spec.Components[depComp.Name]; ok {
|
||||
if depPkgComponent.Enabled != nil && !*depPkgComponent.Enabled {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
depCompNamespace := depComp.Install.Namespace
|
||||
if depCompNamespace == "" {
|
||||
return nil, fmt.Errorf("component %s in dependent Package %s has empty namespace in Install section", depComp.Name, depPackageName)
|
||||
}
|
||||
depCompReleaseName := depComp.Install.ReleaseName
|
||||
if depCompReleaseName == "" {
|
||||
depCompReleaseName = depComp.Name
|
||||
}
|
||||
|
||||
dependsOn = append(dependsOn, helmv2.DependencyReference{
|
||||
Name: depCompReleaseName,
|
||||
Namespace: depCompNamespace,
|
||||
})
|
||||
logger.V(1).Info("added variant dependency", "package", pkg.Name, "dependency", depPackageName, "component", depComp.Name, "releaseName", depCompReleaseName, "namespace", depCompNamespace)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return dependsOn, nil
|
||||
}
|
||||
|
||||
// updateDependenciesStatus updates the dependencies status in Package status
|
||||
// It checks the readiness of each dependency and updates pkg.Status.Dependencies
|
||||
// Old dependency keys that are no longer in the dependency list are removed
|
||||
func (r *PackageReconciler) updateDependenciesStatus(ctx context.Context, pkg *cozyv1alpha1.Package, variant *cozyv1alpha1.Variant) error {
|
||||
logger := log.FromContext(ctx)
|
||||
|
||||
// Initialize dependencies map if nil
|
||||
if pkg.Status.Dependencies == nil {
|
||||
pkg.Status.Dependencies = make(map[string]cozyv1alpha1.DependencyStatus)
|
||||
}
|
||||
|
||||
// Build set of current dependencies (excluding ignored ones)
|
||||
currentDeps := make(map[string]bool)
|
||||
if len(variant.DependsOn) > 0 {
|
||||
for _, depPackageName := range variant.DependsOn {
|
||||
// Check if dependency is in IgnoreDependencies
|
||||
ignore := false
|
||||
for _, ignoreDep := range pkg.Spec.IgnoreDependencies {
|
||||
if ignoreDep == depPackageName {
|
||||
ignore = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if ignore {
|
||||
logger.V(1).Info("ignoring dependency", "package", pkg.Name, "dependency", depPackageName)
|
||||
continue
|
||||
}
|
||||
currentDeps[depPackageName] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Remove old dependencies that are no longer in the list
|
||||
for depName := range pkg.Status.Dependencies {
|
||||
if !currentDeps[depName] {
|
||||
delete(pkg.Status.Dependencies, depName)
|
||||
logger.V(1).Info("removed old dependency from status", "package", pkg.Name, "dependency", depName)
|
||||
}
|
||||
}
|
||||
|
||||
// Update status for each current dependency
|
||||
for depPackageName := range currentDeps {
|
||||
// Get the Package
|
||||
depPackage := &cozyv1alpha1.Package{}
|
||||
if err := r.Get(ctx, types.NamespacedName{Name: depPackageName}, depPackage); err != nil {
|
||||
if apierrors.IsNotFound(err) {
|
||||
// Dependency not found, mark as not ready
|
||||
pkg.Status.Dependencies[depPackageName] = cozyv1alpha1.DependencyStatus{
|
||||
Ready: false,
|
||||
}
|
||||
logger.V(1).Info("dependency not found, marking as not ready", "package", pkg.Name, "dependency", depPackageName)
|
||||
continue
|
||||
}
|
||||
// Error getting dependency, keep existing status or mark as not ready
|
||||
if _, exists := pkg.Status.Dependencies[depPackageName]; !exists {
|
||||
pkg.Status.Dependencies[depPackageName] = cozyv1alpha1.DependencyStatus{
|
||||
Ready: false,
|
||||
}
|
||||
}
|
||||
logger.V(1).Error(err, "failed to get dependency, keeping existing status", "package", pkg.Name, "dependency", depPackageName)
|
||||
continue
|
||||
}
|
||||
|
||||
// Check Ready condition
|
||||
readyCondition := meta.FindStatusCondition(depPackage.Status.Conditions, "Ready")
|
||||
isReady := readyCondition != nil && readyCondition.Status == metav1.ConditionTrue
|
||||
|
||||
// Update dependency status
|
||||
pkg.Status.Dependencies[depPackageName] = cozyv1alpha1.DependencyStatus{
|
||||
Ready: isReady,
|
||||
}
|
||||
logger.V(1).Info("updated dependency status", "package", pkg.Name, "dependency", depPackageName, "ready", isReady)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// areDependenciesReady checks if all dependencies are ready based on status
|
||||
func (r *PackageReconciler) areDependenciesReady(pkg *cozyv1alpha1.Package, variant *cozyv1alpha1.Variant) bool {
|
||||
if len(variant.DependsOn) == 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
for _, depPackageName := range variant.DependsOn {
|
||||
// Check if dependency is in IgnoreDependencies
|
||||
ignore := false
|
||||
for _, ignoreDep := range pkg.Spec.IgnoreDependencies {
|
||||
if ignoreDep == depPackageName {
|
||||
ignore = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if ignore {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check dependency status
|
||||
depStatus, exists := pkg.Status.Dependencies[depPackageName]
|
||||
if !exists || !depStatus.Ready {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// updateDependentPackagesDependencies updates dependencies status for all Packages that depend on the given Package
|
||||
// This ensures dependent packages get re-enqueued when their dependency status changes
|
||||
func (r *PackageReconciler) updateDependentPackagesDependencies(ctx context.Context, packageName string) error {
|
||||
logger := log.FromContext(ctx)
|
||||
|
||||
// Get all Packages
|
||||
packageList := &cozyv1alpha1.PackageList{}
|
||||
if err := r.List(ctx, packageList); err != nil {
|
||||
return fmt.Errorf("failed to list Packages: %w", err)
|
||||
}
|
||||
|
||||
// Get the updated Package to check its readiness
|
||||
updatedPkg := &cozyv1alpha1.Package{}
|
||||
if err := r.Get(ctx, types.NamespacedName{Name: packageName}, updatedPkg); err != nil {
|
||||
if apierrors.IsNotFound(err) {
|
||||
return nil // Package not found, nothing to update
|
||||
}
|
||||
return fmt.Errorf("failed to get Package %s: %w", packageName, err)
|
||||
}
|
||||
|
||||
// Check Ready condition of the updated Package
|
||||
readyCondition := meta.FindStatusCondition(updatedPkg.Status.Conditions, "Ready")
|
||||
isReady := readyCondition != nil && readyCondition.Status == metav1.ConditionTrue
|
||||
|
||||
// For each Package, check if it depends on the given Package
|
||||
for _, pkg := range packageList.Items {
|
||||
// Skip the Package itself
|
||||
if pkg.Name == packageName {
|
||||
continue
|
||||
}
|
||||
|
||||
// Get variant
|
||||
variant, err := r.getVariantForPackage(ctx, &pkg, nil)
|
||||
if err != nil {
|
||||
// Continue if PackageSource or variant not found (best-effort operation)
|
||||
logger.V(1).Info("skipping package, failed to get variant", "package", pkg.Name, "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if this Package depends on the given Package
|
||||
dependsOn := false
|
||||
for _, dep := range variant.DependsOn {
|
||||
// Check if dependency is in IgnoreDependencies
|
||||
ignore := false
|
||||
for _, ignoreDep := range pkg.Spec.IgnoreDependencies {
|
||||
if ignoreDep == dep {
|
||||
ignore = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if ignore {
|
||||
continue
|
||||
}
|
||||
|
||||
if dep == packageName {
|
||||
dependsOn = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if dependsOn {
|
||||
// Update the dependency status in this Package
|
||||
if pkg.Status.Dependencies == nil {
|
||||
pkg.Status.Dependencies = make(map[string]cozyv1alpha1.DependencyStatus)
|
||||
}
|
||||
pkg.Status.Dependencies[packageName] = cozyv1alpha1.DependencyStatus{
|
||||
Ready: isReady,
|
||||
}
|
||||
if err := r.Status().Update(ctx, &pkg); err != nil {
|
||||
logger.V(1).Error(err, "failed to update dependency status for dependent Package", "package", pkg.Name, "dependency", packageName)
|
||||
continue
|
||||
}
|
||||
logger.V(1).Info("updated dependency status for dependent Package", "package", pkg.Name, "dependency", packageName, "ready", isReady)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// reconcileNamespaces creates or updates namespaces based on components in the variant
|
||||
func (r *PackageReconciler) reconcileNamespaces(ctx context.Context, pkg *cozyv1alpha1.Package, variant *cozyv1alpha1.Variant) error {
|
||||
logger := log.FromContext(ctx)
|
||||
|
||||
// Collect namespaces from components
|
||||
// Map: namespace -> {isPrivileged}
|
||||
type namespaceInfo struct {
|
||||
privileged bool
|
||||
}
|
||||
namespacesMap := make(map[string]namespaceInfo)
|
||||
|
||||
for _, component := range variant.Components {
|
||||
// Skip components without Install section
|
||||
if component.Install == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if component is disabled via Package spec
|
||||
if pkgComponent, ok := pkg.Spec.Components[component.Name]; ok {
|
||||
if pkgComponent.Enabled != nil && !*pkgComponent.Enabled {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Namespace must be set
|
||||
namespace := component.Install.Namespace
|
||||
if namespace == "" {
|
||||
return fmt.Errorf("component %s has empty namespace in Install section", component.Name)
|
||||
}
|
||||
|
||||
info, exists := namespacesMap[namespace]
|
||||
if !exists {
|
||||
info = namespaceInfo{
|
||||
privileged: false,
|
||||
}
|
||||
}
|
||||
|
||||
// If component is privileged, mark namespace as privileged
|
||||
if component.Install.Privileged {
|
||||
info.privileged = true
|
||||
}
|
||||
|
||||
namespacesMap[namespace] = info
|
||||
}
|
||||
|
||||
// Create or update all namespaces
|
||||
for nsName, info := range namespacesMap {
|
||||
namespace := &corev1.Namespace{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: nsName,
|
||||
Labels: make(map[string]string),
|
||||
Annotations: map[string]string{
|
||||
"helm.sh/resource-policy": "keep",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Add system label only for non-tenant namespaces
|
||||
if !strings.HasPrefix(nsName, "tenant-") {
|
||||
namespace.Labels["cozystack.io/system"] = "true"
|
||||
}
|
||||
|
||||
// Add privileged label if needed
|
||||
if info.privileged {
|
||||
namespace.Labels["pod-security.kubernetes.io/enforce"] = "privileged"
|
||||
}
|
||||
|
||||
if err := r.createOrUpdateNamespace(ctx, namespace); err != nil {
|
||||
logger.Error(err, "failed to reconcile namespace", "name", nsName, "privileged", info.privileged)
|
||||
return fmt.Errorf("failed to reconcile namespace %s: %w", nsName, err)
|
||||
}
|
||||
logger.Info("reconciled namespace", "name", nsName, "privileged", info.privileged)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// createOrUpdateNamespace creates or updates a namespace using server-side apply
|
||||
func (r *PackageReconciler) createOrUpdateNamespace(ctx context.Context, namespace *corev1.Namespace) error {
|
||||
// Ensure TypeMeta is set for server-side apply
|
||||
namespace.SetGroupVersionKind(corev1.SchemeGroupVersion.WithKind("Namespace"))
|
||||
|
||||
// Use server-side apply with field manager
|
||||
// This is atomic and avoids race conditions from Get/Create/Update pattern
|
||||
// Labels and annotations will be merged automatically by the server
|
||||
// Each label/annotation key is treated as a separate field, so existing ones are preserved
|
||||
return r.Patch(ctx, namespace, client.Apply, client.FieldOwner("cozystack-package-controller"))
|
||||
}
|
||||
|
||||
// cleanupOrphanedHelmReleases removes HelmReleases that are no longer needed
|
||||
func (r *PackageReconciler) cleanupOrphanedHelmReleases(ctx context.Context, pkg *cozyv1alpha1.Package, variant *cozyv1alpha1.Variant) error {
|
||||
logger := log.FromContext(ctx)
|
||||
|
||||
// Build map of desired HelmRelease names (from components with Install)
|
||||
desiredReleases := make(map[types.NamespacedName]bool)
|
||||
for _, component := range variant.Components {
|
||||
if component.Install == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if component is disabled via Package spec
|
||||
if pkgComponent, ok := pkg.Spec.Components[component.Name]; ok {
|
||||
if pkgComponent.Enabled != nil && !*pkgComponent.Enabled {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
namespace := component.Install.Namespace
|
||||
if namespace == "" {
|
||||
// Skip components with empty namespace (they shouldn't exist anyway)
|
||||
continue
|
||||
}
|
||||
|
||||
releaseName := component.Install.ReleaseName
|
||||
if releaseName == "" {
|
||||
releaseName = component.Name
|
||||
}
|
||||
|
||||
desiredReleases[types.NamespacedName{
|
||||
Name: releaseName,
|
||||
Namespace: namespace,
|
||||
}] = true
|
||||
}
|
||||
|
||||
// Find all HelmReleases owned by this Package
|
||||
hrList := &helmv2.HelmReleaseList{}
|
||||
if err := r.List(ctx, hrList, client.MatchingLabels{
|
||||
"cozystack.io/package": pkg.Name,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Delete HelmReleases that are not in desired list
|
||||
for _, hr := range hrList.Items {
|
||||
key := types.NamespacedName{
|
||||
Name: hr.Name,
|
||||
Namespace: hr.Namespace,
|
||||
}
|
||||
if !desiredReleases[key] {
|
||||
logger.Info("deleting orphaned HelmRelease", "name", hr.Name, "namespace", hr.Namespace, "package", pkg.Name)
|
||||
if err := r.Delete(ctx, &hr); err != nil && !apierrors.IsNotFound(err) {
|
||||
logger.Error(err, "failed to delete orphaned HelmRelease", "name", hr.Name, "namespace", hr.Namespace)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetupWithManager sets up the controller with the Manager.
|
||||
func (r *PackageReconciler) SetupWithManager(mgr ctrl.Manager) error {
|
||||
return ctrl.NewControllerManagedBy(mgr).
|
||||
Named("cozystack-package").
|
||||
For(&cozyv1alpha1.Package{}).
|
||||
Owns(&helmv2.HelmRelease{}).
|
||||
Watches(
|
||||
&cozyv1alpha1.PackageSource{},
|
||||
handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, obj client.Object) []reconcile.Request {
|
||||
ps, ok := obj.(*cozyv1alpha1.PackageSource)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
// Find Package with the same name as PackageSource
|
||||
// PackageSource and Package share the same name
|
||||
pkg := &cozyv1alpha1.Package{}
|
||||
if err := mgr.GetClient().Get(ctx, types.NamespacedName{Name: ps.Name}, pkg); err != nil {
|
||||
// Package not found, that's ok - it might not exist yet
|
||||
return nil
|
||||
}
|
||||
// Trigger reconcile for the corresponding Package
|
||||
return []reconcile.Request{{
|
||||
NamespacedName: types.NamespacedName{
|
||||
Name: pkg.Name,
|
||||
},
|
||||
}}
|
||||
}),
|
||||
).
|
||||
Watches(
|
||||
&cozyv1alpha1.Package{},
|
||||
handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, obj client.Object) []reconcile.Request {
|
||||
updatedPkg, ok := obj.(*cozyv1alpha1.Package)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
// Find all Packages that depend on this Package
|
||||
packageList := &cozyv1alpha1.PackageList{}
|
||||
if err := mgr.GetClient().List(ctx, packageList); err != nil {
|
||||
return nil
|
||||
}
|
||||
var requests []reconcile.Request
|
||||
for _, pkg := range packageList.Items {
|
||||
if pkg.Name == updatedPkg.Name {
|
||||
continue // Skip the Package itself
|
||||
}
|
||||
// Get variant to check dependencies
|
||||
variant, err := r.getVariantForPackage(ctx, &pkg, mgr.GetClient())
|
||||
if err != nil {
|
||||
// Continue if PackageSource or variant not found
|
||||
continue
|
||||
}
|
||||
// Check if this variant depends on updatedPkg
|
||||
for _, dep := range variant.DependsOn {
|
||||
// Check if dependency is in IgnoreDependencies
|
||||
ignore := false
|
||||
for _, ignoreDep := range pkg.Spec.IgnoreDependencies {
|
||||
if ignoreDep == dep {
|
||||
ignore = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if ignore {
|
||||
continue
|
||||
}
|
||||
if dep == updatedPkg.Name {
|
||||
requests = append(requests, reconcile.Request{
|
||||
NamespacedName: types.NamespacedName{
|
||||
Name: pkg.Name,
|
||||
},
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return requests
|
||||
}),
|
||||
).
|
||||
Complete(r)
|
||||
}
|
||||
413
internal/operator/packagesource_reconciler.go
Normal file
413
internal/operator/packagesource_reconciler.go
Normal file
@@ -0,0 +1,413 @@
|
||||
/*
|
||||
Copyright 2025 The Cozystack 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 operator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
cozyv1alpha1 "github.com/cozystack/cozystack/api/v1alpha1"
|
||||
sourcewatcherv1beta1 "github.com/fluxcd/source-watcher/api/v2/v1beta1"
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
"k8s.io/apimachinery/pkg/api/meta"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
ctrl "sigs.k8s.io/controller-runtime"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client/apiutil"
|
||||
"sigs.k8s.io/controller-runtime/pkg/log"
|
||||
)
|
||||
|
||||
// PackageSourceReconciler reconciles PackageSource resources
|
||||
type PackageSourceReconciler struct {
|
||||
client.Client
|
||||
Scheme *runtime.Scheme
|
||||
}
|
||||
|
||||
// +kubebuilder:rbac:groups=cozystack.io,resources=packagesources,verbs=get;list;watch;create;update;patch;delete
|
||||
// +kubebuilder:rbac:groups=cozystack.io,resources=packagesources/status,verbs=get;update;patch
|
||||
// +kubebuilder:rbac:groups=source.extensions.fluxcd.io,resources=artifactgenerators,verbs=get;list;watch;create;update;patch;delete
|
||||
|
||||
// Reconcile is part of the main kubernetes reconciliation loop
|
||||
func (r *PackageSourceReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
|
||||
logger := log.FromContext(ctx)
|
||||
|
||||
packageSource := &cozyv1alpha1.PackageSource{}
|
||||
if err := r.Get(ctx, req.NamespacedName, packageSource); err != nil {
|
||||
if apierrors.IsNotFound(err) {
|
||||
// Resource not found, return (ownerReference will handle cleanup)
|
||||
return ctrl.Result{}, nil
|
||||
}
|
||||
return ctrl.Result{}, err
|
||||
}
|
||||
|
||||
// Generate ArtifactGenerator for package source
|
||||
if err := r.reconcileArtifactGenerators(ctx, packageSource); err != nil {
|
||||
logger.Error(err, "failed to reconcile ArtifactGenerator")
|
||||
return ctrl.Result{}, err
|
||||
}
|
||||
|
||||
// Update PackageSource status (variants and conditions from ArtifactGenerator)
|
||||
if err := r.updateStatus(ctx, packageSource); err != nil {
|
||||
logger.Error(err, "failed to update status")
|
||||
// Don't return error, status update is not critical
|
||||
}
|
||||
|
||||
return ctrl.Result{}, nil
|
||||
}
|
||||
|
||||
// reconcileArtifactGenerators generates a single ArtifactGenerator for the package source
|
||||
// Creates one ArtifactGenerator per package source with all OutputArtifacts from components
|
||||
func (r *PackageSourceReconciler) reconcileArtifactGenerators(ctx context.Context, packageSource *cozyv1alpha1.PackageSource) error {
|
||||
logger := log.FromContext(ctx)
|
||||
|
||||
// Check if SourceRef is set
|
||||
if packageSource.Spec.SourceRef == nil {
|
||||
logger.Info("skipping ArtifactGenerator creation, SourceRef not set", "packageSource", packageSource.Name)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Namespace is always cozy-system
|
||||
namespace := "cozy-system"
|
||||
// ArtifactGenerator name is the package source name
|
||||
agName := packageSource.Name
|
||||
|
||||
// Collect all OutputArtifacts
|
||||
outputArtifacts := []sourcewatcherv1beta1.OutputArtifact{}
|
||||
|
||||
// Process all variants and their components
|
||||
for _, variant := range packageSource.Spec.Variants {
|
||||
// Build library map for this variant
|
||||
// Map key is the library name (from lib.Name or extracted from path)
|
||||
// This allows components in this variant to reference libraries by name
|
||||
// Libraries are scoped per variant to avoid conflicts between variants
|
||||
libraryMap := make(map[string]cozyv1alpha1.Library)
|
||||
for _, lib := range variant.Libraries {
|
||||
libName := lib.Name
|
||||
if libName == "" {
|
||||
// If library name is not set, extract from path
|
||||
libName = r.getPackageNameFromPath(lib.Path)
|
||||
}
|
||||
if libName != "" {
|
||||
// Store library with the resolved name
|
||||
libraryMap[libName] = lib
|
||||
}
|
||||
}
|
||||
|
||||
for _, component := range variant.Components {
|
||||
// Skip components without path
|
||||
if component.Path == "" {
|
||||
logger.V(1).Info("skipping component without path", "packageSource", packageSource.Name, "variant", variant.Name, "component", component.Name)
|
||||
continue
|
||||
}
|
||||
|
||||
logger.V(1).Info("processing component", "packageSource", packageSource.Name, "variant", variant.Name, "component", component.Name, "path", component.Path)
|
||||
|
||||
// Extract component name from path (last component)
|
||||
componentPathName := r.getPackageNameFromPath(component.Path)
|
||||
if componentPathName == "" {
|
||||
logger.Info("skipping component with invalid path", "packageSource", packageSource.Name, "variant", variant.Name, "component", component.Name, "path", component.Path)
|
||||
continue
|
||||
}
|
||||
|
||||
// Get basePath with default values
|
||||
basePath := r.getBasePath(packageSource)
|
||||
|
||||
// Build copy operations
|
||||
copyOps := []sourcewatcherv1beta1.CopyOperation{
|
||||
{
|
||||
From: r.buildSourcePath(packageSource.Spec.SourceRef.Name, basePath, component.Path),
|
||||
To: fmt.Sprintf("@artifact/%s/", componentPathName),
|
||||
},
|
||||
}
|
||||
|
||||
// Add libraries if specified
|
||||
for _, libName := range component.Libraries {
|
||||
if lib, ok := libraryMap[libName]; ok {
|
||||
copyOps = append(copyOps, sourcewatcherv1beta1.CopyOperation{
|
||||
From: r.buildSourcePath(packageSource.Spec.SourceRef.Name, basePath, lib.Path),
|
||||
To: fmt.Sprintf("@artifact/%s/charts/%s/", componentPathName, libName),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Add valuesFiles if specified
|
||||
for i, valuesFile := range component.ValuesFiles {
|
||||
strategy := "Merge"
|
||||
if i == 0 {
|
||||
strategy = "Overwrite"
|
||||
}
|
||||
copyOps = append(copyOps, sourcewatcherv1beta1.CopyOperation{
|
||||
From: r.buildSourceFilePath(packageSource.Spec.SourceRef.Name, basePath, fmt.Sprintf("%s/%s", component.Path, valuesFile)),
|
||||
To: fmt.Sprintf("@artifact/%s/values.yaml", componentPathName),
|
||||
Strategy: strategy,
|
||||
})
|
||||
}
|
||||
|
||||
// Artifact name: <packagesource>-<variant>-<componentname>
|
||||
// Replace dots with dashes to comply with Kubernetes naming requirements
|
||||
artifactName := fmt.Sprintf("%s-%s-%s",
|
||||
strings.ReplaceAll(packageSource.Name, ".", "-"),
|
||||
strings.ReplaceAll(variant.Name, ".", "-"),
|
||||
strings.ReplaceAll(component.Name, ".", "-"))
|
||||
|
||||
outputArtifacts = append(outputArtifacts, sourcewatcherv1beta1.OutputArtifact{
|
||||
Name: artifactName,
|
||||
Copy: copyOps,
|
||||
})
|
||||
|
||||
logger.Info("added OutputArtifact for component", "packageSource", packageSource.Name, "variant", variant.Name, "component", component.Name, "artifactName", artifactName)
|
||||
}
|
||||
}
|
||||
|
||||
// If there are no OutputArtifacts, return (ownerReference will handle cleanup if needed)
|
||||
if len(outputArtifacts) == 0 {
|
||||
logger.Info("no OutputArtifacts to generate, skipping ArtifactGenerator creation", "packageSource", packageSource.Name)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Build labels
|
||||
labels := make(map[string]string)
|
||||
labels["cozystack.io/packagesource"] = packageSource.Name
|
||||
|
||||
// Create single ArtifactGenerator for the package source
|
||||
ag := &sourcewatcherv1beta1.ArtifactGenerator{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: agName,
|
||||
Namespace: namespace,
|
||||
Labels: labels,
|
||||
},
|
||||
Spec: sourcewatcherv1beta1.ArtifactGeneratorSpec{
|
||||
Sources: []sourcewatcherv1beta1.SourceReference{
|
||||
{
|
||||
Alias: packageSource.Spec.SourceRef.Name,
|
||||
Kind: packageSource.Spec.SourceRef.Kind,
|
||||
Name: packageSource.Spec.SourceRef.Name,
|
||||
Namespace: packageSource.Spec.SourceRef.Namespace,
|
||||
},
|
||||
},
|
||||
OutputArtifacts: outputArtifacts,
|
||||
},
|
||||
}
|
||||
|
||||
// Set ownerReference
|
||||
gvk, err := apiutil.GVKForObject(packageSource, r.Scheme)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get GVK for PackageSource: %w", err)
|
||||
}
|
||||
ag.OwnerReferences = []metav1.OwnerReference{
|
||||
{
|
||||
APIVersion: gvk.GroupVersion().String(),
|
||||
Kind: gvk.Kind,
|
||||
Name: packageSource.Name,
|
||||
UID: packageSource.UID,
|
||||
Controller: func() *bool { b := true; return &b }(),
|
||||
},
|
||||
}
|
||||
|
||||
logger.Info("creating ArtifactGenerator for package source", "packageSource", packageSource.Name, "agName", agName, "namespace", namespace, "outputArtifactCount", len(outputArtifacts))
|
||||
|
||||
if err := r.createOrUpdate(ctx, ag); err != nil {
|
||||
return fmt.Errorf("failed to reconcile ArtifactGenerator %s: %w", agName, err)
|
||||
}
|
||||
|
||||
logger.Info("reconciled ArtifactGenerator for package source", "name", agName, "namespace", namespace, "outputArtifactCount", len(outputArtifacts))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
func (r *PackageSourceReconciler) getPackageNameFromPath(path string) string {
|
||||
parts := strings.Split(path, "/")
|
||||
if len(parts) > 0 {
|
||||
return parts[len(parts)-1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// getBasePath returns the basePath with default values based on source kind
|
||||
func (r *PackageSourceReconciler) getBasePath(packageSource *cozyv1alpha1.PackageSource) string {
|
||||
// If path is explicitly set in SourceRef, use it (but normalize "/" to empty)
|
||||
if packageSource.Spec.SourceRef.Path != "" {
|
||||
path := strings.Trim(packageSource.Spec.SourceRef.Path, "/")
|
||||
// If path is "/" or empty after trim, return empty string
|
||||
if path == "" {
|
||||
return ""
|
||||
}
|
||||
return path
|
||||
}
|
||||
// Default values based on kind
|
||||
if packageSource.Spec.SourceRef.Kind == "OCIRepository" {
|
||||
return "" // Root for OCI
|
||||
}
|
||||
// Default for GitRepository
|
||||
return "packages"
|
||||
}
|
||||
|
||||
// buildSourcePath builds the full source path using basePath with glob pattern
|
||||
func (r *PackageSourceReconciler) buildSourcePath(sourceName, basePath, path string) string {
|
||||
// Remove leading/trailing slashes and combine
|
||||
parts := []string{}
|
||||
if basePath != "" {
|
||||
trimmed := strings.Trim(basePath, "/")
|
||||
if trimmed != "" {
|
||||
parts = append(parts, trimmed)
|
||||
}
|
||||
}
|
||||
if path != "" {
|
||||
trimmed := strings.Trim(path, "/")
|
||||
if trimmed != "" {
|
||||
parts = append(parts, trimmed)
|
||||
}
|
||||
}
|
||||
|
||||
fullPath := strings.Join(parts, "/")
|
||||
if fullPath == "" {
|
||||
return fmt.Sprintf("@%s/**", sourceName)
|
||||
}
|
||||
return fmt.Sprintf("@%s/%s/**", sourceName, fullPath)
|
||||
}
|
||||
|
||||
// buildSourceFilePath builds the full source path for a specific file (without glob pattern)
|
||||
func (r *PackageSourceReconciler) buildSourceFilePath(sourceName, basePath, path string) string {
|
||||
// Remove leading/trailing slashes and combine
|
||||
parts := []string{}
|
||||
if basePath != "" {
|
||||
trimmed := strings.Trim(basePath, "/")
|
||||
if trimmed != "" {
|
||||
parts = append(parts, trimmed)
|
||||
}
|
||||
}
|
||||
if path != "" {
|
||||
trimmed := strings.Trim(path, "/")
|
||||
if trimmed != "" {
|
||||
parts = append(parts, trimmed)
|
||||
}
|
||||
}
|
||||
|
||||
fullPath := strings.Join(parts, "/")
|
||||
if fullPath == "" {
|
||||
return fmt.Sprintf("@%s", sourceName)
|
||||
}
|
||||
return fmt.Sprintf("@%s/%s", sourceName, fullPath)
|
||||
}
|
||||
|
||||
// createOrUpdate creates or updates a resource using server-side apply
|
||||
func (r *PackageSourceReconciler) createOrUpdate(ctx context.Context, obj client.Object) error {
|
||||
// Ensure TypeMeta is set for server-side apply
|
||||
// Use type assertion to set GVK if the object supports it
|
||||
if runtimeObj, ok := obj.(runtime.Object); ok {
|
||||
gvk, err := apiutil.GVKForObject(obj, r.Scheme)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get GVK for object: %w", err)
|
||||
}
|
||||
runtimeObj.GetObjectKind().SetGroupVersionKind(gvk)
|
||||
}
|
||||
|
||||
// Use server-side apply with field manager
|
||||
// This is atomic and avoids race conditions from Get/Create/Update pattern
|
||||
// Labels, annotations, and spec will be merged automatically by the server
|
||||
// Each field is treated separately, so existing ones are preserved
|
||||
return r.Patch(ctx, obj, client.Apply, client.FieldOwner("cozystack-packagesource-controller"))
|
||||
}
|
||||
|
||||
// updateStatus updates PackageSource status (variants and conditions from ArtifactGenerator)
|
||||
func (r *PackageSourceReconciler) updateStatus(ctx context.Context, packageSource *cozyv1alpha1.PackageSource) error {
|
||||
logger := log.FromContext(ctx)
|
||||
|
||||
// Update variants in status from spec
|
||||
variantNames := make([]string, 0, len(packageSource.Spec.Variants))
|
||||
for _, variant := range packageSource.Spec.Variants {
|
||||
variantNames = append(variantNames, variant.Name)
|
||||
}
|
||||
packageSource.Status.Variants = strings.Join(variantNames, ",")
|
||||
|
||||
// Check if SourceRef is set
|
||||
if packageSource.Spec.SourceRef == nil {
|
||||
// Set status to unknown if SourceRef is not set
|
||||
meta.SetStatusCondition(&packageSource.Status.Conditions, metav1.Condition{
|
||||
Type: "Ready",
|
||||
Status: metav1.ConditionUnknown,
|
||||
Reason: "SourceRefNotSet",
|
||||
Message: "SourceRef is not configured",
|
||||
})
|
||||
return r.Status().Update(ctx, packageSource)
|
||||
}
|
||||
|
||||
// Get ArtifactGenerator
|
||||
ag := &sourcewatcherv1beta1.ArtifactGenerator{}
|
||||
agKey := types.NamespacedName{
|
||||
Name: packageSource.Name,
|
||||
Namespace: "cozy-system",
|
||||
}
|
||||
|
||||
if err := r.Get(ctx, agKey, ag); err != nil {
|
||||
if apierrors.IsNotFound(err) {
|
||||
// ArtifactGenerator not found, set status to unknown
|
||||
meta.SetStatusCondition(&packageSource.Status.Conditions, metav1.Condition{
|
||||
Type: "Ready",
|
||||
Status: metav1.ConditionUnknown,
|
||||
Reason: "ArtifactGeneratorNotFound",
|
||||
Message: "ArtifactGenerator not found",
|
||||
})
|
||||
return r.Status().Update(ctx, packageSource)
|
||||
}
|
||||
return fmt.Errorf("failed to get ArtifactGenerator: %w", err)
|
||||
}
|
||||
|
||||
// Find Ready condition in ArtifactGenerator
|
||||
readyCondition := meta.FindStatusCondition(ag.Status.Conditions, "Ready")
|
||||
if readyCondition == nil {
|
||||
// No Ready condition in ArtifactGenerator, set status to unknown
|
||||
meta.SetStatusCondition(&packageSource.Status.Conditions, metav1.Condition{
|
||||
Type: "Ready",
|
||||
Status: metav1.ConditionUnknown,
|
||||
Reason: "ArtifactGeneratorNotReady",
|
||||
Message: "ArtifactGenerator Ready condition not found",
|
||||
})
|
||||
return r.Status().Update(ctx, packageSource)
|
||||
}
|
||||
|
||||
// Copy Ready condition from ArtifactGenerator to PackageSource
|
||||
meta.SetStatusCondition(&packageSource.Status.Conditions, metav1.Condition{
|
||||
Type: "Ready",
|
||||
Status: readyCondition.Status,
|
||||
Reason: readyCondition.Reason,
|
||||
Message: readyCondition.Message,
|
||||
ObservedGeneration: packageSource.Generation,
|
||||
LastTransitionTime: readyCondition.LastTransitionTime,
|
||||
})
|
||||
|
||||
logger.V(1).Info("updated PackageSource status from ArtifactGenerator",
|
||||
"packageSource", packageSource.Name,
|
||||
"status", readyCondition.Status,
|
||||
"reason", readyCondition.Reason)
|
||||
|
||||
return r.Status().Update(ctx, packageSource)
|
||||
}
|
||||
|
||||
// SetupWithManager sets up the controller with the Manager.
|
||||
func (r *PackageSourceReconciler) SetupWithManager(mgr ctrl.Manager) error {
|
||||
return ctrl.NewControllerManagedBy(mgr).
|
||||
Named("cozystack-packagesource").
|
||||
For(&cozyv1alpha1.PackageSource{}).
|
||||
Owns(&sourcewatcherv1beta1.ArtifactGenerator{}).
|
||||
Complete(r)
|
||||
}
|
||||
|
||||
68
internal/template/template.go
Normal file
68
internal/template/template.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package template
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
tmpl "text/template"
|
||||
)
|
||||
|
||||
func Template[T any](obj *T, templateContext map[string]any) (*T, error) {
|
||||
b, err := json.Marshal(obj)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var unstructured any
|
||||
err = json.Unmarshal(b, &unstructured)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
templateFunc := func(in string) string {
|
||||
out, err := template(in, templateContext)
|
||||
if err != nil {
|
||||
return in
|
||||
}
|
||||
return out
|
||||
}
|
||||
unstructured = mapAtStrings(unstructured, templateFunc)
|
||||
b, err = json.Marshal(unstructured)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out T
|
||||
err = json.Unmarshal(b, &out)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func mapAtStrings(v any, f func(string) string) any {
|
||||
switch x := v.(type) {
|
||||
case map[string]any:
|
||||
for k, val := range x {
|
||||
x[k] = mapAtStrings(val, f)
|
||||
}
|
||||
return x
|
||||
case []any:
|
||||
for i, val := range x {
|
||||
x[i] = mapAtStrings(val, f)
|
||||
}
|
||||
return x
|
||||
case string:
|
||||
return f(x)
|
||||
default:
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
func template(in string, templateContext map[string]any) (string, error) {
|
||||
tpl, err := tmpl.New("this").Parse(in)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := tpl.Execute(&buf, templateContext); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return buf.String(), nil
|
||||
}
|
||||
68
internal/template/template_test.go
Normal file
68
internal/template/template_test.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package template
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
)
|
||||
|
||||
func TestTemplate_PodTemplateSpec(t *testing.T) {
|
||||
original := corev1.PodTemplateSpec{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "my-pod",
|
||||
Labels: map[string]string{
|
||||
"app": "demo",
|
||||
},
|
||||
Annotations: map[string]string{
|
||||
"note": "hello",
|
||||
},
|
||||
},
|
||||
Spec: corev1.PodSpec{
|
||||
Containers: []corev1.Container{
|
||||
{
|
||||
Name: "{{ .Release.Name }}",
|
||||
Image: "nginx:1.21",
|
||||
Args: []string{"--flag={{ .Values.value }}"},
|
||||
Env: []corev1.EnvVar{
|
||||
{
|
||||
Name: "FOO",
|
||||
Value: "{{ .Release.Namespace }}",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
templateContext := map[string]any{
|
||||
"Release": map[string]any{
|
||||
"Name": "foo",
|
||||
"Namespace": "notdefault",
|
||||
},
|
||||
"Values": map[string]any{
|
||||
"value": 3,
|
||||
},
|
||||
}
|
||||
reference := *original.DeepCopy()
|
||||
reference.Spec.Containers[0].Name = "foo"
|
||||
reference.Spec.Containers[0].Args[0] = "--flag=3"
|
||||
reference.Spec.Containers[0].Env[0].Value = "notdefault"
|
||||
got, err := Template(&original, templateContext)
|
||||
if err != nil {
|
||||
t.Fatalf("Template returned error: %v", err)
|
||||
}
|
||||
b1, err := json.Marshal(reference)
|
||||
t.Logf("reference:\n%s", string(b1))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal reference value: %v", err)
|
||||
}
|
||||
b2, err := json.Marshal(got)
|
||||
t.Logf("got:\n%s", string(b2))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal transformed value: %v", err)
|
||||
}
|
||||
if string(b1) != string(b2) {
|
||||
t.Fatalf("transformed value not equal to reference value, expected: %s, got: %s", string(b1), string(b2))
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
{{- $myNS := lookup "v1" "Namespace" "" .Release.Namespace }}
|
||||
{{- $seaweedfs := index $myNS.metadata.annotations "namespace.cozystack.io/seaweedfs" }}
|
||||
{{- $seaweedfs := .Values._namespace.seaweedfs }}
|
||||
apiVersion: objectstorage.k8s.io/v1alpha1
|
||||
kind: BucketClaim
|
||||
metadata:
|
||||
|
||||
@@ -21,5 +21,8 @@ spec:
|
||||
force: true
|
||||
remediation:
|
||||
retries: -1
|
||||
valuesFrom:
|
||||
- kind: Secret
|
||||
name: cozystack-values
|
||||
values:
|
||||
bucketName: {{ .Release.Name }}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
{{- $cozyConfig := lookup "v1" "ConfigMap" "cozy-system" "cozystack" }}
|
||||
{{- $clusterDomain := (index $cozyConfig.data "cluster-domain") | default "cozy.local" }}
|
||||
{{- $clusterDomain := (index .Values._cluster "cluster-domain") | default "cozy.local" }}
|
||||
|
||||
{{- if .Values.clickhouseKeeper.enabled }}
|
||||
apiVersion: "clickhouse-keeper.altinity.com/v1"
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
{{- $cozyConfig := lookup "v1" "ConfigMap" "cozy-system" "cozystack" }}
|
||||
{{- $clusterDomain := (index $cozyConfig.data "cluster-domain") | default "cozy.local" }}
|
||||
{{- $clusterDomain := (index .Values._cluster "cluster-domain") | default "cozy.local" }}
|
||||
{{- $existingSecret := lookup "v1" "Secret" .Release.Namespace (printf "%s-credentials" .Release.Name) }}
|
||||
{{- $passwords := dict }}
|
||||
{{- $users := .Values.users }}
|
||||
|
||||
@@ -50,9 +50,8 @@ spec:
|
||||
postgresUID: 999
|
||||
postgresGID: 999
|
||||
enableSuperuserAccess: true
|
||||
{{- $configMap := lookup "v1" "ConfigMap" "cozy-system" "cozystack-scheduling" }}
|
||||
{{- if $configMap }}
|
||||
{{- $rawConstraints := get $configMap.data "globalAppTopologySpreadConstraints" }}
|
||||
{{- if .Values._cluster.scheduling }}
|
||||
{{- $rawConstraints := get .Values._cluster.scheduling "globalAppTopologySpreadConstraints" }}
|
||||
{{- if $rawConstraints }}
|
||||
{{- $rawConstraints | fromYaml | toYaml | nindent 2 }}
|
||||
labelSelector:
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
{{- $cozyConfig := lookup "v1" "ConfigMap" "cozy-system" "cozystack" | default (dict "data" (dict)) }}
|
||||
{{- $clusterDomain := index $cozyConfig.data "cluster-domain" | default "cozy.local" }}
|
||||
{{- $clusterDomain := index .Values._cluster "cluster-domain" | default "cozy.local" }}
|
||||
---
|
||||
apiVersion: apps.foundationdb.org/v1beta2
|
||||
kind: FoundationDBCluster
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
.helmignore
|
||||
/logos
|
||||
/Makefile
|
||||
/hack
|
||||
|
||||
@@ -6,9 +6,12 @@ include ../../../scripts/package.mk
|
||||
|
||||
generate:
|
||||
cozyvalues-gen -v values.yaml -s values.schema.json -r README.md
|
||||
yq -o=json -i '.properties.version.enum = (load("files/versions.yaml") | keys)' values.schema.json
|
||||
../../../hack/update-crd.sh
|
||||
|
||||
update:
|
||||
hack/update-versions.sh
|
||||
make generate
|
||||
|
||||
image: image-ubuntu-container-disk image-kubevirt-cloud-provider image-kubevirt-csi-driver image-cluster-autoscaler
|
||||
|
||||
image-ubuntu-container-disk:
|
||||
|
||||
@@ -104,7 +104,7 @@ See the reference for components utilized in this service:
|
||||
| `nodeGroups[name].resources.memory` | Memory (RAM) available. | `quantity` | `""` |
|
||||
| `nodeGroups[name].gpus` | List of GPUs to attach (NVIDIA driver requires at least 4 GiB RAM). | `[]object` | `[]` |
|
||||
| `nodeGroups[name].gpus[i].name` | Name of GPU, such as "nvidia.com/AD102GL_L40S". | `string` | `""` |
|
||||
| `version` | Kubernetes version (vMAJOR.MINOR). Supported: 1.28–1.33. | `string` | `v1.33` |
|
||||
| `version` | Kubernetes major.minor version to deploy | `string` | `v1.33` |
|
||||
| `host` | External hostname for Kubernetes cluster. Defaults to `<cluster-name>.<tenant-host>` if empty. | `string` | `""` |
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"v1.28": "v1.28.15"
|
||||
"v1.29": "v1.29.15"
|
||||
"v1.30": "v1.30.14"
|
||||
"v1.31": "v1.31.10"
|
||||
"v1.32": "v1.32.6"
|
||||
"v1.33": "v1.33.0"
|
||||
"v1.32": "v1.32.10"
|
||||
"v1.31": "v1.31.14"
|
||||
"v1.30": "v1.30.14"
|
||||
"v1.29": "v1.29.15"
|
||||
"v1.28": "v1.28.15"
|
||||
|
||||
260
packages/apps/kubernetes/hack/update-versions.sh
Executable file
260
packages/apps/kubernetes/hack/update-versions.sh
Executable file
@@ -0,0 +1,260 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -o errexit
|
||||
set -o nounset
|
||||
set -o pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
KUBERNETES_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
VALUES_FILE="${KUBERNETES_DIR}/values.yaml"
|
||||
VERSIONS_FILE="${KUBERNETES_DIR}/files/versions.yaml"
|
||||
MAKEFILE="${KUBERNETES_DIR}/Makefile"
|
||||
KAMAJI_DOCKERFILE="${KUBERNETES_DIR}/../../system/kamaji/images/kamaji/Dockerfile"
|
||||
|
||||
# Check if skopeo is installed
|
||||
if ! command -v skopeo &> /dev/null; then
|
||||
echo "Error: skopeo is not installed. Please install skopeo and try again." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if jq is installed
|
||||
if ! command -v jq &> /dev/null; then
|
||||
echo "Error: jq is not installed. Please install jq and try again." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Get kamaji version from Dockerfile
|
||||
echo "Reading kamaji version from Dockerfile..."
|
||||
if [ ! -f "$KAMAJI_DOCKERFILE" ]; then
|
||||
echo "Error: Kamaji Dockerfile not found at $KAMAJI_DOCKERFILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
KAMAJI_VERSION=$(grep "^ARG VERSION=" "$KAMAJI_DOCKERFILE" | cut -d= -f2 | tr -d '"')
|
||||
if [ -z "$KAMAJI_VERSION" ]; then
|
||||
echo "Error: Could not extract kamaji version from Dockerfile" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Kamaji version: $KAMAJI_VERSION"
|
||||
|
||||
# Get Kubernetes version from kamaji repository
|
||||
echo "Fetching Kubernetes version from kamaji repository..."
|
||||
KUBERNETES_VERSION_FROM_KAMAJI=$(curl -sSL "https://raw.githubusercontent.com/clastix/kamaji/${KAMAJI_VERSION}/internal/upgrade/kubeadm_version.go" | grep "KubeadmVersion" | sed -E 's/.*KubeadmVersion = "([^"]+)".*/\1/')
|
||||
|
||||
if [ -z "$KUBERNETES_VERSION_FROM_KAMAJI" ]; then
|
||||
echo "Error: Could not fetch Kubernetes version from kamaji repository" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Kubernetes version from kamaji: $KUBERNETES_VERSION_FROM_KAMAJI"
|
||||
|
||||
# Extract major.minor version (e.g., "1.33" from "v1.33.0")
|
||||
KUBERNETES_MAJOR_MINOR=$(echo "$KUBERNETES_VERSION_FROM_KAMAJI" | sed -E 's/v([0-9]+)\.([0-9]+)\.[0-9]+/\1.\2/')
|
||||
KUBERNETES_MAJOR=$(echo "$KUBERNETES_MAJOR_MINOR" | cut -d. -f1)
|
||||
KUBERNETES_MINOR=$(echo "$KUBERNETES_MAJOR_MINOR" | cut -d. -f2)
|
||||
|
||||
echo "Kubernetes major.minor: $KUBERNETES_MAJOR_MINOR"
|
||||
|
||||
# Get available image tags
|
||||
echo "Fetching available image tags from registry..."
|
||||
AVAILABLE_TAGS=$(skopeo list-tags docker://registry.k8s.io/kube-apiserver | jq -r '.Tags[] | select(test("^v[0-9]+\\.[0-9]+\\.[0-9]+$"))' | sort -V)
|
||||
|
||||
if [ -z "$AVAILABLE_TAGS" ]; then
|
||||
echo "Error: Could not fetch available image tags" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Filter out versions higher than KUBERNETES_VERSION_FROM_KAMAJI
|
||||
echo "Filtering versions above ${KUBERNETES_VERSION_FROM_KAMAJI}..."
|
||||
FILTERED_TAGS=$(echo "$AVAILABLE_TAGS" | while read tag; do
|
||||
if [ -n "$tag" ]; then
|
||||
# Compare tag with KUBERNETES_VERSION_FROM_KAMAJI using version sort
|
||||
# Include tag if it's less than or equal to KUBERNETES_VERSION_FROM_KAMAJI
|
||||
if [ "$(printf '%s\n%s\n' "$tag" "$KUBERNETES_VERSION_FROM_KAMAJI" | sort -V | head -1)" = "$tag" ] || [ "$tag" = "$KUBERNETES_VERSION_FROM_KAMAJI" ]; then
|
||||
echo "$tag"
|
||||
fi
|
||||
fi
|
||||
done)
|
||||
|
||||
if [ -z "$FILTERED_TAGS" ]; then
|
||||
echo "Error: No versions found after filtering" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
AVAILABLE_TAGS="$FILTERED_TAGS"
|
||||
echo "Filtered to $(echo "$AVAILABLE_TAGS" | wc -l | tr -d ' ') versions"
|
||||
|
||||
# Find the latest patch version for the supported major.minor version
|
||||
echo "Finding latest patch version for ${KUBERNETES_MAJOR_MINOR}..."
|
||||
SUPPORTED_PATCH_TAGS=$(echo "$AVAILABLE_TAGS" | grep "^v${KUBERNETES_MAJOR}\\.${KUBERNETES_MINOR}\\.")
|
||||
if [ -z "$SUPPORTED_PATCH_TAGS" ]; then
|
||||
echo "Error: Could not find any patch versions for ${KUBERNETES_MAJOR_MINOR}" >&2
|
||||
exit 1
|
||||
fi
|
||||
KUBERNETES_VERSION=$(echo "$SUPPORTED_PATCH_TAGS" | tail -n1)
|
||||
echo "Using latest patch version: $KUBERNETES_VERSION"
|
||||
|
||||
# Build versions map: major.minor -> latest patch version
|
||||
# First, collect all unique major.minor versions from available tags
|
||||
echo "Collecting all available major.minor versions..."
|
||||
ALL_MAJOR_MINOR_VERSIONS=$(echo "$AVAILABLE_TAGS" | sed -E 's/v([0-9]+)\.([0-9]+)\.[0-9]+/v\1.\2/' | sort -V -u)
|
||||
|
||||
# Find the position of the supported version in the sorted list
|
||||
SUPPORTED_MAJOR_MINOR="v${KUBERNETES_MAJOR}.${KUBERNETES_MINOR}"
|
||||
echo "Looking for supported version: $SUPPORTED_MAJOR_MINOR"
|
||||
|
||||
# Get all versions that are <= supported version
|
||||
# Create a temporary file for filtering
|
||||
TEMP_VERSIONS=$(mktemp)
|
||||
|
||||
echo "$ALL_MAJOR_MINOR_VERSIONS" | while read version; do
|
||||
# Compare versions using sort -V (version sort)
|
||||
# If version <= supported, include it
|
||||
if [ "$(printf '%s\n%s\n' "$version" "$SUPPORTED_MAJOR_MINOR" | sort -V | head -1)" = "$version" ] || [ "$version" = "$SUPPORTED_MAJOR_MINOR" ]; then
|
||||
echo "$version"
|
||||
fi
|
||||
done > "$TEMP_VERSIONS"
|
||||
|
||||
# Get the supported version and 5 previous versions (total 6 versions)
|
||||
# First, find the position of supported version
|
||||
SUPPORTED_POS=$(grep -n "^${SUPPORTED_MAJOR_MINOR}$" "$TEMP_VERSIONS" | cut -d: -f1)
|
||||
|
||||
if [ -z "$SUPPORTED_POS" ]; then
|
||||
echo "Error: Supported version $SUPPORTED_MAJOR_MINOR not found in available versions" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Calculate start position (5 versions before supported, or from beginning if less than 5 available)
|
||||
TOTAL_LINES=$(wc -l < "$TEMP_VERSIONS" | tr -d ' ')
|
||||
START_POS=$((SUPPORTED_POS - 5))
|
||||
if [ $START_POS -lt 1 ]; then
|
||||
START_POS=1
|
||||
fi
|
||||
|
||||
# Extract versions from START_POS to SUPPORTED_POS (inclusive)
|
||||
CANDIDATE_VERSIONS=$(sed -n "${START_POS},${SUPPORTED_POS}p" "$TEMP_VERSIONS")
|
||||
|
||||
if [ -z "$CANDIDATE_VERSIONS" ]; then
|
||||
echo "Error: Could not find supported version $SUPPORTED_MAJOR_MINOR in available versions" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
declare -A VERSION_MAP
|
||||
VERSIONS=()
|
||||
|
||||
# Process each candidate version
|
||||
for major_minor_key in $CANDIDATE_VERSIONS; do
|
||||
# Extract major and minor for matching
|
||||
major=$(echo "$major_minor_key" | sed -E 's/v([0-9]+)\.([0-9]+)/\1/')
|
||||
minor=$(echo "$major_minor_key" | sed -E 's/v([0-9]+)\.([0-9]+)/\2/')
|
||||
|
||||
# Find all tags that match this major.minor version
|
||||
matching_tags=$(echo "$AVAILABLE_TAGS" | grep "^v${major}\\.${minor}\\.")
|
||||
|
||||
if [ -n "$matching_tags" ]; then
|
||||
# Get the latest patch version for this major.minor version
|
||||
latest_tag=$(echo "$matching_tags" | tail -n1)
|
||||
|
||||
VERSION_MAP["${major_minor_key}"]="${latest_tag}"
|
||||
VERSIONS+=("${major_minor_key}")
|
||||
echo "Found version: ${major_minor_key} -> ${latest_tag}"
|
||||
fi
|
||||
done
|
||||
|
||||
if [ ${#VERSIONS[@]} -eq 0 ]; then
|
||||
echo "Error: No matching versions found" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Sort versions in descending order (newest first)
|
||||
IFS=$'\n' VERSIONS=($(printf '%s\n' "${VERSIONS[@]}" | sort -V -r))
|
||||
unset IFS
|
||||
|
||||
echo "Versions to add: ${VERSIONS[*]}"
|
||||
|
||||
# Create/update versions.yaml file
|
||||
echo "Updating $VERSIONS_FILE..."
|
||||
{
|
||||
for ver in "${VERSIONS[@]}"; do
|
||||
echo "\"${ver}\": \"${VERSION_MAP[$ver]}\""
|
||||
done
|
||||
} > "$VERSIONS_FILE"
|
||||
|
||||
echo "Successfully updated $VERSIONS_FILE"
|
||||
|
||||
# Update values.yaml - enum with major.minor versions only
|
||||
TEMP_FILE=$(mktemp)
|
||||
trap "rm -f $TEMP_FILE $TEMP_VERSIONS" EXIT
|
||||
|
||||
# Build new version section
|
||||
NEW_VERSION_SECTION="## @enum {string} Version"
|
||||
for ver in "${VERSIONS[@]}"; do
|
||||
NEW_VERSION_SECTION="${NEW_VERSION_SECTION}
|
||||
## @value $ver"
|
||||
done
|
||||
NEW_VERSION_SECTION="${NEW_VERSION_SECTION}
|
||||
|
||||
## @param {Version} version - Kubernetes major.minor version to deploy
|
||||
version: \"${VERSIONS[0]}\""
|
||||
|
||||
# Check if version section already exists
|
||||
if grep -q "^## @enum {string} Version" "$VALUES_FILE"; then
|
||||
# Version section exists, update it using awk
|
||||
echo "Updating existing version section in $VALUES_FILE..."
|
||||
|
||||
# Use awk to replace the section from "## @enum {string} Version" to "version: " (inclusive)
|
||||
# Delete the old section and insert the new one
|
||||
awk -v new_section="$NEW_VERSION_SECTION" '
|
||||
/^## @enum {string} Version/ {
|
||||
in_section = 1
|
||||
print new_section
|
||||
next
|
||||
}
|
||||
in_section && /^version: / {
|
||||
in_section = 0
|
||||
next
|
||||
}
|
||||
in_section {
|
||||
next
|
||||
}
|
||||
{ print }
|
||||
' "$VALUES_FILE" > "$TEMP_FILE.tmp"
|
||||
mv "$TEMP_FILE.tmp" "$VALUES_FILE"
|
||||
else
|
||||
# Version section doesn't exist, insert it before Application-specific parameters section
|
||||
echo "Inserting new version section in $VALUES_FILE..."
|
||||
|
||||
# Use awk to insert before "## @section Application-specific parameters"
|
||||
awk -v new_section="$NEW_VERSION_SECTION" '
|
||||
/^## @section Application-specific parameters/ {
|
||||
print new_section
|
||||
print ""
|
||||
}
|
||||
{ print }
|
||||
' "$VALUES_FILE" > "$TEMP_FILE.tmp"
|
||||
mv "$TEMP_FILE.tmp" "$VALUES_FILE"
|
||||
fi
|
||||
|
||||
echo "Successfully updated $VALUES_FILE with versions: ${VERSIONS[*]}"
|
||||
|
||||
# Update KUBERNETES_VERSION in Makefile
|
||||
# Extract major.minor from KUBERNETES_VERSION (e.g., "v1.33" from "v1.33.4")
|
||||
KUBERNETES_MAJOR_MINOR_FOR_MAKEFILE=$(echo "$KUBERNETES_VERSION" | sed -E 's/v([0-9]+)\.([0-9]+)\.[0-9]+/v\1.\2/')
|
||||
|
||||
if grep -q "^KUBERNETES_VERSION" "$MAKEFILE"; then
|
||||
# Update existing KUBERNETES_VERSION line using awk
|
||||
echo "Updating KUBERNETES_VERSION in $MAKEFILE..."
|
||||
awk -v new_version="${KUBERNETES_MAJOR_MINOR_FOR_MAKEFILE}" '
|
||||
/^KUBERNETES_VERSION = / {
|
||||
print "KUBERNETES_VERSION = " new_version
|
||||
next
|
||||
}
|
||||
{ print }
|
||||
' "$MAKEFILE" > "$TEMP_FILE.tmp"
|
||||
mv "$TEMP_FILE.tmp" "$MAKEFILE"
|
||||
echo "Successfully updated KUBERNETES_VERSION in $MAKEFILE to ${KUBERNETES_MAJOR_MINOR_FOR_MAKEFILE}"
|
||||
else
|
||||
echo "Warning: KUBERNETES_VERSION not found in $MAKEFILE" >&2
|
||||
fi
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
diff --git a/pkg/controller/kubevirteps/kubevirteps_controller.go b/pkg/controller/kubevirteps/kubevirteps_controller.go
|
||||
index 53388eb8e..28644236f 100644
|
||||
index 53388eb8e..873060251 100644
|
||||
--- a/pkg/controller/kubevirteps/kubevirteps_controller.go
|
||||
+++ b/pkg/controller/kubevirteps/kubevirteps_controller.go
|
||||
@@ -12,7 +12,6 @@ import (
|
||||
@@ -10,12 +10,17 @@ index 53388eb8e..28644236f 100644
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
utilruntime "k8s.io/apimachinery/pkg/util/runtime"
|
||||
@@ -669,35 +668,50 @@ func (c *Controller) getDesiredEndpoints(service *v1.Service, tenantSlices []*di
|
||||
@@ -666,38 +665,62 @@ func (c *Controller) getDesiredEndpoints(service *v1.Service, tenantSlices []*di
|
||||
// for extracting the nodes it does not matter what type of address we are dealing with
|
||||
// all nodes with an endpoint for a corresponding slice will be selected.
|
||||
nodeSet := sets.Set[string]{}
|
||||
+ hasEndpointsWithoutNodeName := false
|
||||
for _, slice := range tenantSlices {
|
||||
for _, endpoint := range slice.Endpoints {
|
||||
// find all unique nodes that correspond to an endpoint in a tenant slice
|
||||
+ if endpoint.NodeName == nil {
|
||||
+ klog.Warningf("Skipping endpoint without NodeName in slice %s/%s", slice.Namespace, slice.Name)
|
||||
+ hasEndpointsWithoutNodeName = true
|
||||
+ continue
|
||||
+ }
|
||||
nodeSet.Insert(*endpoint.NodeName)
|
||||
@@ -23,6 +28,13 @@ index 53388eb8e..28644236f 100644
|
||||
}
|
||||
|
||||
- klog.Infof("Desired nodes for service %s in namespace %s: %v", service.Name, service.Namespace, sets.List(nodeSet))
|
||||
+ // Fallback: if no endpoints with NodeName were found, but there are endpoints without NodeName,
|
||||
+ // distribute traffic to all VMIs (similar to ExternalTrafficPolicy=Cluster behavior)
|
||||
+ if nodeSet.Len() == 0 && hasEndpointsWithoutNodeName {
|
||||
+ klog.Infof("No endpoints with NodeName found for service %s/%s, falling back to all VMIs", service.Namespace, service.Name)
|
||||
+ return c.getAllVMIEndpoints()
|
||||
+ }
|
||||
+
|
||||
+ klog.Infof("Desired nodes for service %s/%s: %v", service.Namespace, service.Name, sets.List(nodeSet))
|
||||
|
||||
for _, node := range sets.List(nodeSet) {
|
||||
@@ -68,7 +80,7 @@ index 53388eb8e..28644236f 100644
|
||||
desiredEndpoints = append(desiredEndpoints, &discovery.Endpoint{
|
||||
Addresses: []string{i.IP},
|
||||
Conditions: discovery.EndpointConditions{
|
||||
@@ -705,9 +719,9 @@ func (c *Controller) getDesiredEndpoints(service *v1.Service, tenantSlices []*di
|
||||
@@ -705,9 +728,9 @@ func (c *Controller) getDesiredEndpoints(service *v1.Service, tenantSlices []*di
|
||||
Serving: &serving,
|
||||
Terminating: &terminating,
|
||||
},
|
||||
@@ -80,6 +92,71 @@ index 53388eb8e..28644236f 100644
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -716,6 +739,64 @@ func (c *Controller) getDesiredEndpoints(service *v1.Service, tenantSlices []*di
|
||||
return desiredEndpoints
|
||||
}
|
||||
|
||||
+// getAllVMIEndpoints returns endpoints for all VMIs in the infra namespace.
|
||||
+// This is used as a fallback when tenant endpoints don't have NodeName specified,
|
||||
+// similar to ExternalTrafficPolicy=Cluster behavior where traffic is distributed to all nodes.
|
||||
+func (c *Controller) getAllVMIEndpoints() []*discovery.Endpoint {
|
||||
+ var endpoints []*discovery.Endpoint
|
||||
+
|
||||
+ // List all VMIs in the infra namespace
|
||||
+ vmiList, err := c.infraDynamic.
|
||||
+ Resource(kubevirtv1.VirtualMachineInstanceGroupVersionKind.GroupVersion().WithResource("virtualmachineinstances")).
|
||||
+ Namespace(c.infraNamespace).
|
||||
+ List(context.TODO(), metav1.ListOptions{})
|
||||
+ if err != nil {
|
||||
+ klog.Errorf("Failed to list VMIs in namespace %q: %v", c.infraNamespace, err)
|
||||
+ return endpoints
|
||||
+ }
|
||||
+
|
||||
+ for _, obj := range vmiList.Items {
|
||||
+ vmi := &kubevirtv1.VirtualMachineInstance{}
|
||||
+ err = runtime.DefaultUnstructuredConverter.FromUnstructured(obj.Object, vmi)
|
||||
+ if err != nil {
|
||||
+ klog.Errorf("Failed to convert Unstructured to VirtualMachineInstance: %v", err)
|
||||
+ continue
|
||||
+ }
|
||||
+
|
||||
+ if vmi.Status.NodeName == "" {
|
||||
+ klog.Warningf("Skipping VMI %s/%s: NodeName is empty", vmi.Namespace, vmi.Name)
|
||||
+ continue
|
||||
+ }
|
||||
+ nodeNamePtr := &vmi.Status.NodeName
|
||||
+
|
||||
+ ready := vmi.Status.Phase == kubevirtv1.Running
|
||||
+ serving := vmi.Status.Phase == kubevirtv1.Running
|
||||
+ terminating := vmi.Status.Phase == kubevirtv1.Failed || vmi.Status.Phase == kubevirtv1.Succeeded
|
||||
+
|
||||
+ for _, i := range vmi.Status.Interfaces {
|
||||
+ if i.Name == "default" {
|
||||
+ if i.IP == "" {
|
||||
+ klog.Warningf("VMI %s/%s interface %q has no IP, skipping", vmi.Namespace, vmi.Name, i.Name)
|
||||
+ continue
|
||||
+ }
|
||||
+ endpoints = append(endpoints, &discovery.Endpoint{
|
||||
+ Addresses: []string{i.IP},
|
||||
+ Conditions: discovery.EndpointConditions{
|
||||
+ Ready: &ready,
|
||||
+ Serving: &serving,
|
||||
+ Terminating: &terminating,
|
||||
+ },
|
||||
+ NodeName: nodeNamePtr,
|
||||
+ })
|
||||
+ break
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ klog.Infof("Fallback: created %d endpoints from all VMIs in namespace %s", len(endpoints), c.infraNamespace)
|
||||
+ return endpoints
|
||||
+}
|
||||
+
|
||||
func (c *Controller) ensureEndpointSliceLabels(slice *discovery.EndpointSlice, svc *v1.Service) (map[string]string, bool) {
|
||||
labels := make(map[string]string)
|
||||
labelsChanged := false
|
||||
diff --git a/pkg/controller/kubevirteps/kubevirteps_controller_test.go b/pkg/controller/kubevirteps/kubevirteps_controller_test.go
|
||||
index 1c97035b4..d205d0bed 100644
|
||||
--- a/pkg/controller/kubevirteps/kubevirteps_controller_test.go
|
||||
@@ -1,7 +1,6 @@
|
||||
{{- $myNS := lookup "v1" "Namespace" "" .Release.Namespace }}
|
||||
{{- $etcd := index $myNS.metadata.annotations "namespace.cozystack.io/etcd" }}
|
||||
{{- $ingress := index $myNS.metadata.annotations "namespace.cozystack.io/ingress" }}
|
||||
{{- $host := index $myNS.metadata.annotations "namespace.cozystack.io/host" }}
|
||||
{{- $etcd := .Values._namespace.etcd }}
|
||||
{{- $ingress := .Values._namespace.ingress }}
|
||||
{{- $host := .Values._namespace.host }}
|
||||
{{- $kubevirtmachinetemplateNames := list }}
|
||||
{{- define "kubevirtmachinetemplate" -}}
|
||||
spec:
|
||||
@@ -31,9 +30,8 @@ spec:
|
||||
{{- end }}
|
||||
cluster.x-k8s.io/deployment-name: {{ $.Release.Name }}-{{ .groupName }}
|
||||
spec:
|
||||
{{- $configMap := lookup "v1" "ConfigMap" "cozy-system" "cozystack-scheduling" }}
|
||||
{{- if $configMap }}
|
||||
{{- $rawConstraints := get $configMap.data "globalAppTopologySpreadConstraints" }}
|
||||
{{- if .Values._cluster.scheduling }}
|
||||
{{- $rawConstraints := get .Values._cluster.scheduling "globalAppTopologySpreadConstraints" }}
|
||||
{{- if $rawConstraints }}
|
||||
{{- $rawConstraints | fromYaml | toYaml | nindent 10 }}
|
||||
labelSelector:
|
||||
|
||||
@@ -5,8 +5,8 @@ metadata:
|
||||
annotations:
|
||||
"helm.sh/hook": pre-delete
|
||||
"helm.sh/hook-weight": "10"
|
||||
"helm.sh/hook-delete-policy": hook-succeeded,before-hook-creation,hook-failed
|
||||
name: {{ .Release.Name }}-cleanup
|
||||
"helm.sh/hook-delete-policy": hook-succeeded,before-hook-creation
|
||||
name: {{ .Release.Name }}-pre-cleanup
|
||||
spec:
|
||||
template:
|
||||
metadata:
|
||||
@@ -65,7 +65,6 @@ spec:
|
||||
|
||||
echo "Cleanup completed successfully"
|
||||
|
||||
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
@@ -73,7 +72,7 @@ metadata:
|
||||
name: {{ .Release.Name }}-cleanup
|
||||
annotations:
|
||||
helm.sh/hook: pre-delete
|
||||
helm.sh/hook-delete-policy: before-hook-creation,hook-failed,hook-succeeded
|
||||
helm.sh/hook-delete-policy: hook-succeeded,before-hook-creation,hook-failed
|
||||
helm.sh/hook-weight: "0"
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
|
||||
@@ -45,8 +45,6 @@ spec:
|
||||
- name: {{ .Release.Name }}
|
||||
namespace: {{ .Release.Namespace }}
|
||||
{{- end }}
|
||||
- name: {{ .Release.Name }}-cilium
|
||||
namespace: {{ .Release.Namespace }}
|
||||
{{- if .Values.addons.certManager.valuesOverride }}
|
||||
---
|
||||
apiVersion: v1
|
||||
|
||||
@@ -37,6 +37,8 @@ spec:
|
||||
dependsOn:
|
||||
- name: {{ .Release.Name }}-vsnap-crd
|
||||
namespace: {{ .Release.Namespace }}
|
||||
- name: {{ .Release.Name }}-cilium
|
||||
namespace: {{ .Release.Namespace }}
|
||||
{{- if lookup "helm.toolkit.fluxcd.io/v2" "HelmRelease" .Release.Namespace .Release.Name }}
|
||||
- name: {{ .Release.Name }}
|
||||
namespace: {{ .Release.Namespace }}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
apiVersion: helm.toolkit.fluxcd.io/v2
|
||||
kind: HelmRelease
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-metrics-server
|
||||
labels:
|
||||
cozystack.io/repository: system
|
||||
cozystack.io/target-cluster-name: {{ .Release.Name }}
|
||||
spec:
|
||||
releaseName: metrics-server
|
||||
chart:
|
||||
spec:
|
||||
chart: cozy-metrics-server
|
||||
reconcileStrategy: Revision
|
||||
sourceRef:
|
||||
kind: HelmRepository
|
||||
name: cozystack-system
|
||||
namespace: cozy-system
|
||||
version: '>= 0.0.0-0'
|
||||
kubeConfig:
|
||||
secretRef:
|
||||
name: {{ .Release.Name }}-admin-kubeconfig
|
||||
key: super-admin.svc
|
||||
targetNamespace: cozy-monitoring
|
||||
storageNamespace: cozy-monitoring
|
||||
interval: 5m
|
||||
timeout: 10m
|
||||
install:
|
||||
createNamespace: true
|
||||
remediation:
|
||||
retries: -1
|
||||
upgrade:
|
||||
remediation:
|
||||
retries: -1
|
||||
dependsOn:
|
||||
{{- if lookup "helm.toolkit.fluxcd.io/v2" "HelmRelease" .Release.Namespace .Release.Name }}
|
||||
- name: {{ .Release.Name }}
|
||||
namespace: {{ .Release.Namespace }}
|
||||
{{- end }}
|
||||
- name: {{ .Release.Name }}-cilium
|
||||
namespace: {{ .Release.Namespace }}
|
||||
- name: {{ .Release.Name }}-prometheus-operator-crds
|
||||
namespace: {{ .Release.Namespace }}
|
||||
@@ -1,5 +1,4 @@
|
||||
{{- $myNS := lookup "v1" "Namespace" "" .Release.Namespace }}
|
||||
{{- $targetTenant := index $myNS.metadata.annotations "namespace.cozystack.io/monitoring" }}
|
||||
{{- $targetTenant := .Values._namespace.monitoring }}
|
||||
{{- if .Values.addons.monitoringAgents.enabled }}
|
||||
apiVersion: helm.toolkit.fluxcd.io/v2
|
||||
kind: HelmRelease
|
||||
@@ -44,6 +43,10 @@ spec:
|
||||
namespace: {{ .Release.Namespace }}
|
||||
- name: {{ .Release.Name }}-vertical-pod-autoscaler-crds
|
||||
namespace: {{ .Release.Namespace }}
|
||||
- name: {{ .Release.Name }}-prometheus-operator-crds
|
||||
namespace: {{ .Release.Namespace }}
|
||||
- name: {{ .Release.Name }}-metrics-server
|
||||
namespace: {{ .Release.Namespace }}
|
||||
values:
|
||||
vmagent:
|
||||
externalLabels:
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
apiVersion: helm.toolkit.fluxcd.io/v2
|
||||
kind: HelmRelease
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-prometheus-operator-crds
|
||||
labels:
|
||||
cozystack.io/repository: system
|
||||
cozystack.io/target-cluster-name: {{ .Release.Name }}
|
||||
spec:
|
||||
releaseName: prometheus-operator-crds
|
||||
chart:
|
||||
spec:
|
||||
chart: cozy-prometheus-operator-crds
|
||||
reconcileStrategy: Revision
|
||||
sourceRef:
|
||||
kind: HelmRepository
|
||||
name: cozystack-system
|
||||
namespace: cozy-system
|
||||
version: '>= 0.0.0-0'
|
||||
kubeConfig:
|
||||
secretRef:
|
||||
name: {{ .Release.Name }}-admin-kubeconfig
|
||||
key: super-admin.svc
|
||||
targetNamespace: cozy-victoria-metrics-operator
|
||||
storageNamespace: cozy-victoria-metrics-operator
|
||||
interval: 5m
|
||||
install:
|
||||
createNamespace: true
|
||||
remediation:
|
||||
retries: -1
|
||||
upgrade:
|
||||
remediation:
|
||||
retries: -1
|
||||
dependsOn:
|
||||
{{- if lookup "helm.toolkit.fluxcd.io/v2" "HelmRelease" .Release.Namespace .Release.Name }}
|
||||
- name: {{ .Release.Name }}
|
||||
namespace: {{ .Release.Namespace }}
|
||||
{{- end }}
|
||||
@@ -37,6 +37,4 @@ spec:
|
||||
- name: {{ .Release.Name }}
|
||||
namespace: {{ .Release.Namespace }}
|
||||
{{- end }}
|
||||
- name: {{ .Release.Name }}-cilium
|
||||
namespace: {{ .Release.Namespace }}
|
||||
{{- end }}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
{{- define "cozystack.defaultVPAValues" -}}
|
||||
{{- $cozyConfig := lookup "v1" "ConfigMap" "cozy-system" "cozystack" }}
|
||||
{{- $clusterDomain := (index $cozyConfig.data "cluster-domain") | default "cozy.local" }}
|
||||
{{- $myNS := lookup "v1" "Namespace" "" .Release.Namespace }}
|
||||
{{- $targetTenant := index $myNS.metadata.annotations "namespace.cozystack.io/monitoring" }}
|
||||
{{- $clusterDomain := (index .Values._cluster "cluster-domain") | default "cozy.local" }}
|
||||
{{- $targetTenant := .Values._namespace.monitoring }}
|
||||
vpaForVPA: false
|
||||
vertical-pod-autoscaler:
|
||||
recommender:
|
||||
|
||||
@@ -37,5 +37,3 @@ spec:
|
||||
- name: {{ .Release.Name }}
|
||||
namespace: {{ .Release.Namespace }}
|
||||
{{- end }}
|
||||
- name: {{ .Release.Name }}-cilium
|
||||
namespace: {{ .Release.Namespace }}
|
||||
@@ -1,5 +1,4 @@
|
||||
{{- $myNS := lookup "v1" "Namespace" "" .Release.Namespace }}
|
||||
{{- $ingress := index $myNS.metadata.annotations "namespace.cozystack.io/ingress" }}
|
||||
{{- $ingress := .Values._namespace.ingress }}
|
||||
{{- if and (eq .Values.addons.ingressNginx.exposeMethod "Proxied") .Values.addons.ingressNginx.hosts }}
|
||||
---
|
||||
apiVersion: networking.k8s.io/v1
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user