diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 000000000..f5250aafb --- /dev/null +++ b/.editorconfig @@ -0,0 +1,11 @@ +root = true + +[*] +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +charset = utf-8 + +[*.{html,java,jelly,xml}] +indent_style = space +indent_size = 2 diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 000000000..fb83349a8 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @jenkinsci/lockable-resources-plugin-developers diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 000000000..98f334645 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,114 @@ +# Copilot Instructions — Lockable Resources Plugin + +This file tells GitHub Copilot (and other AI assistants) about the project's +coding standards, PR requirements, and review checklist so they are applied +automatically when generating code, commit messages, or PR descriptions. + +## Project overview + +Jenkins plugin that lets builds declare **lockable resources** (printers, +phones, lab machines, etc.). If a resource is already locked, the build waits +until it is free. + +- Language: **Java 17+** (Maven build, Jenkins HPI packaging) +- Localization: managed via [Crowdin](https://crowdin.jenkins.io/lockable-resources-plugin); all strings live in `*.properties` files +- UI examples: documented under `src/doc/examples/` + +## Issue linking + +- Reference a Jira issue as `JENKINS-XXXXX` with a link to `https://issues.jenkins.io/browse/JENKINS-XXXXX`. +- Reference a GitHub issue as `#XXXXX`. +- Use closing keywords (`Fixes #XXXXX`) when the PR fully resolves an issue. +- Minor improvements do not require a tracking issue. +- Bug fixes **should** have a tracking issue to facilitate backporting. +- Major new features **must** have a tracking issue. + +## Commit & PR title conventions + +- The PR title is used as the **changelog entry** — write it in imperative mood + (e.g. "Add timeout parameter to lock step"). +- Follow the style of https://github.com/jenkins-infra/jenkins.io/blob/main/content/_data/changelogs/weekly.yml + +## Coding rules + +| Area | Rule | +|------|------| +| Internal-only public API | Annotate with `@NoExternalUse`; if called from Jelly, add `Used by {@code .jelly}` Javadoc | +| New public classes/fields/methods | Annotate with `@Restricted` or add `@since TODO` Javadoc | +| Deprecations | Use `@Deprecated(since = "TODO")` or `@Deprecated(forRemoval = true, since = "TODO")` | +| JavaScript | No inline scripts, no `eval()` — support future CSP directives (see [docs](https://www.jenkins.io/doc/developer/security/csp/)) | +| Localizations | Always use `*.properties` files; English strings are mandatory for every new key | + +## Testing requirements + +- Every change **must** have automated tests, or the PR must explain why tests + are not feasible. +- A green CI build alone does not prove the changed lines were executed — + describe the test scenario if coverage is missing. +- Frontend changes: include before/after screenshots. +- Refactoring: exercise the code before and after and confirm identical behavior. + +## PR labels (automatic) + +PRs are auto-labeled by two workflows: + +### File-based labels (`actions/labeler`) + +| Label | Paths | +|-------|-------| +| `java` | `src/main/java/**` | +| `tests` | `src/test/**` | +| `ci` | `.github/workflows/**`, `Jenkinsfile` | +| `dependencies` | `pom.xml` | +| `documentation` | `*.md`, `src/doc/**` | +| `localization` | `src/main/resources/**/*.properties` | +| `frontend` | `src/main/webapp/**`, `src/main/resources/**/*.jelly` | +| `chore` | `.github/**`, `.gitignore`, `crowdin.yml` | + +### Release-drafter labels (`auto-label-pr.yml`) + +Added based on PR title/branch (for changelog generation): + +| Label | Trigger (title/branch) | +|-------|------------------------| +| `breaking` | Title contains "breaking" or "!:" | +| `major-enhancement` | Title contains "major" + "feat/enhancement" | +| `major-bug` | Title contains "major" + "fix/bug" | +| `deprecated` | Title contains "deprecat" | +| `removed` | Title contains "remove" | +| `enhancement` | Title starts with "feat"/"add" or branch `feature/*` | +| `bug` | Title starts with "fix" or branch `fix/*` | +| `documentation` | Title starts with "docs" or branch `docs/*` | +| `chore` | Title starts with "chore"/"ci:"/"build:" | +| `refactor` | Title starts with "refactor" | +| `security` | Title contains "security"/"cve-" | + +### Auto-approve countdown (owner/member PRs) + +For PRs by OWNER, MEMBER, or COLLABORATOR (non-draft), the label `merge-in-3-days-without-review` +is automatically added to start the auto-approve countdown. + +## Dependency updates + +- Include links to external changelogs and, if possible, full diffs. +- For new APIs or extension points, link to at least one consumer. + +## Upgrade guidelines + +- Only needed for breaking changes or changes requiring manual user action. +- When applicable, set the `upgrade-guide-needed` label. + +## Maintainer merge checklist + +Before marking `ready-for-merge`: + +1. At least **one approval** with no outstanding change requests. +2. All conversations resolved (or reviewer explicitly not blocking). +3. PR title is an accurate, imperative-mood changelog entry. +4. Correct release-drafter labels are set (see [label config](https://github.com/jenkinsci/.github/blob/ce466227c534c42820a597cb8e9cac2f2334920a/.github/release-drafter.yml#L9-L50)). +5. Java code changes are covered by automated tests. + +## Interface changes + +Document any UI or pipeline-DSL changes as examples under +[src/doc/examples/](src/doc/examples/readme.md). diff --git a/.github/copilot-pr-review-rules.md b/.github/copilot-pr-review-rules.md new file mode 100644 index 000000000..d0af7eb31 --- /dev/null +++ b/.github/copilot-pr-review-rules.md @@ -0,0 +1,134 @@ +# Copilot Instructions — PR Review & Cleanup Rules + +This file defines the mandatory checklist Copilot must follow when reviewing, +cleaning up, or triaging pull requests in this repository. + +> **Context:** This plugin is large, old, and highly complex. Regressions and +> performance degradation are real risks. Every PR review must account for +> testing depth and runtime impact — not just correctness. + +## PR review workflow + +When asked to review or clean up a PR, always follow these steps **in order**: + +### 1. Validate PR description + +- Read the PR title and body. +- Verify the description accurately reflects the actual code changes. +- Flag mismatches: wrong description, copy-paste errors, or stale text from + a template that was not filled in. + +### 2. Validate linked items + +- Check linked issues (Jira `JENKINS-XXXXX` or GitHub `#XXXXX`). +- Confirm the linked issue matches the intent of the PR. +- Flag PRs that reference an unrelated or wrong issue. +- Minor improvements may omit an issue; bug fixes and features must have one. + +### 3. Validate the implementation + +- Review the diff: does the code change actually solve the described problem? +- Look for incomplete changes, dead code, debug leftovers, or unrelated edits + that got mixed in. +- Ensure new code follows the project coding rules (see `copilot-instructions.md`). + +### 4. Review all comments — open AND closed + +- **Open comments** must be resolved or explicitly accepted with a meaningful + response before the PR can be merged. No open comment may be ignored. +- **Closed / resolved comments** should be read for context — they reveal + prior reviewer concerns, rejected approaches, and design decisions. + +### 5. Propose a continuation plan + +After reviewing the PR state, always provide **at least one concrete proposal** +for how to move the PR forward. This should include: + +- What is missing or broken and what needs to change. +- Specific next steps (code changes, rebases, comment resolutions). +- Whether the PR should be continued, split, or closed in favour of a fresh PR. +- If the PR has been idle for a long time, assess whether the approach is still + viable given the current state of `master`. + +### 6. Define testing — manual AND automated + +Testing is **mandatory** for every non-trivial change. The plugin is complex +and regressions are costly. Always provide: + +#### Manual testing steps + +- Describe concrete steps to verify the change locally: + ``` + mvn hpi:run + # then in Jenkins UI → Lockable Resources → ... + ``` +- Include expected outcomes (what the user should see / not see). +- For lock/unlock logic changes, describe multi-job concurrency scenarios. +- For UI changes, list which pages to check and what to look for. + +#### Automated testing requirements + +- **Unit tests** (`src/test/java/...`) for any new or changed logic. +- **Integration tests** using `JenkinsRule` for pipeline-level behaviour. +- **Configuration-as-Code tests** (`ConfigurationAsCodeTest`) when config + structures change. +- If the PR lacks tests, write them or explain precisely why they cannot be + added — "no tests" without justification is not acceptable. +- Run the full test suite before approving: + ``` + mvn clean verify + ``` +- If tests are flaky or slow, note it — do not silently skip them. + +### 7. Evaluate performance impact + +This plugin manages shared resources under contention. Performance matters. + +- **Lock/unlock hot paths:** Changes to `LockableResourcesManager`, + `LockStepExecution`, or queue handling must be evaluated for: + - Lock contention and synchronization overhead + - Iteration over all resources (O(n) scans) + - Unnecessary save/persist calls (`save()` triggers disk I/O) +- **Memory:** Watch for resource leaks, unbounded collections, or retained + references to `Run` / `FlowNode` objects. +- **Scalability:** Consider behaviour with 100+ resources and 50+ concurrent + builds waiting for locks. +- If a change affects a hot path, request or write a benchmark scenario: + ```java + // Example: time lock acquisition with N resources and M waiters + ``` +- Flag any change that adds `synchronized` blocks, new `save()` calls, or + full-collection scans without justification. + +### 8. Update the source branch + +- The source branch **must** be up-to-date with the target before merge. +- If the branch is behind, rebase or merge the target into the source branch + first. +- Never merge a PR with an outdated source branch. + +### 9. Target branch rules + +- The default target branch is **`master`**. +- A different target branch is only acceptable for long-running feature + development branches (explicitly agreed upon beforehand). +- If the target branch looks wrong, flag it immediately. + +## Summary checklist + +Use this quick checklist when processing any PR: + +``` +[ ] Description matches the actual changes +[ ] Linked issue (if any) is correct and relevant +[ ] Implementation solves the described problem +[ ] No unrelated changes mixed in +[ ] All open comments resolved or explicitly accepted +[ ] Closed comments reviewed for context +[ ] Continuation plan proposed (next steps / split / close) +[ ] Manual testing steps documented +[ ] Automated tests present or justified absence +[ ] Performance impact evaluated (hot paths, sync, save, memory) +[ ] Source branch is up-to-date with target +[ ] Target branch is master (or justified exception) +``` diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..fdc58d1e9 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,12 @@ +# https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates + +version: 2 +updates: + - package-ecosystem: "maven" + directory: "/" + schedule: + interval: "weekly" + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" diff --git a/.github/labeler.yml b/.github/labeler.yml new file mode 100644 index 000000000..ff80e06f3 --- /dev/null +++ b/.github/labeler.yml @@ -0,0 +1,44 @@ +# .github/labeler.yml — actions/labeler v5 schema +# Labels PRs based on changed files. +# For release-drafter compatibility, see also: auto-label-pr.yml + +# ═══════════════════════════════════════════════════════════════════ +# File-based labels (auto-applied by actions/labeler) +# ═══════════════════════════════════════════════════════════════════ + +java: + - changed-files: + - any-glob-to-any-file: 'src/main/java/**' + +tests: + - changed-files: + - any-glob-to-any-file: 'src/test/**' + +ci: + - changed-files: + - any-glob-to-any-file: '.github/workflows/**' + - any-glob-to-any-file: 'Jenkinsfile' + +dependencies: + - changed-files: + - any-glob-to-any-file: 'pom.xml' + +documentation: + - changed-files: + - any-glob-to-any-file: '*.md' + - any-glob-to-any-file: 'src/doc/**' + +localization: + - changed-files: + - any-glob-to-any-file: 'src/main/resources/**/*.properties' + +frontend: + - changed-files: + - any-glob-to-any-file: 'src/main/webapp/**' + - any-glob-to-any-file: 'src/main/resources/**/*.jelly' + +chore: + - changed-files: + - any-glob-to-any-file: '.github/**' + - any-glob-to-any-file: '.gitignore' + - any-glob-to-any-file: 'crowdin.yml' diff --git a/.github/workflows/auto-approve-owner-prs.yml b/.github/workflows/auto-approve-owner-prs.yml new file mode 100644 index 000000000..0ff593355 --- /dev/null +++ b/.github/workflows/auto-approve-owner-prs.yml @@ -0,0 +1,32 @@ +# Auto-approve owner PRs that receive no review within 3 days. +# +# Uses the reusable action: https://github.com/mPokornyETM/auto-approve-stale-prs +# +# Countdown labels give reviewers clear visibility: +# Day 0 → merge-in-3-days-without-review +# Day 1 → merge-in-2-days-without-review +# Day 2 → merge-in-1-day-without-review +# Day 3 → approved + merged-without-review (auto-merge takes over) +# +# Only PRs authored by a repo OWNER or MEMBER are processed. +# PRs that already have at least one approval are skipped. +name: Auto-approve owner PRs + +on: + schedule: + - cron: "0 8 * * *" # daily at 08:00 UTC + workflow_dispatch: # allow manual trigger + +permissions: + contents: write + pull-requests: write + +jobs: + countdown: + runs-on: ubuntu-latest + steps: + - uses: mPokornyETM/auto-approve-stale-prs@v1 + with: + days-until-approve: '3' + author-associations: 'OWNER' + merge-method: 'squash' diff --git a/.github/workflows/auto-label-pr.yml b/.github/workflows/auto-label-pr.yml new file mode 100644 index 000000000..c7e1b9382 --- /dev/null +++ b/.github/workflows/auto-label-pr.yml @@ -0,0 +1,149 @@ +# Auto-label PRs based on title, branch, and author association. +# Adds release-drafter compatible labels for changelog generation. +# Also adds auto-approve countdown label for owner/member PRs. +name: Auto-label PR + +on: + # Use pull_request_target so the workflow has write permissions even for fork PRs. + # SECURITY: Do not check out or execute code from the PR in this workflow. + pull_request_target: + types: [opened, edited, synchronize, reopened] + +permissions: + contents: read + pull-requests: write + issues: write + +jobs: + auto-label: + runs-on: ubuntu-latest + steps: + - name: Auto-label based on title and branch + uses: actions/github-script@v9 + with: + script: | + const pr = context.payload.pull_request; + const title = pr.title.toLowerCase(); + const branch = pr.head.ref.toLowerCase(); + const author = pr.user.login; + const authorAssoc = pr.author_association; + const isDraft = pr.draft; + + const labelsToAdd = []; + const labelsToRemove = []; + + // Get current labels + const currentLabels = pr.labels.map(l => l.name); + + console.log(`PR #${pr.number}: "${pr.title}"`); + console.log(` Branch: ${branch}`); + console.log(` Author: ${author} (${authorAssoc})`); + console.log(` Draft: ${isDraft}`); + console.log(` Current labels: ${currentLabels.join(', ') || 'none'}`); + + // ═══════════════════════════════════════════════════════════════ + // Release-drafter compatible labels based on title/branch + // ═══════════════════════════════════════════════════════════════ + + // Breaking changes + if (title.includes('breaking') || title.includes('!:') || branch.startsWith('breaking/')) { + labelsToAdd.push('breaking'); + } + + // Major enhancements + if (title.includes('major') && (title.includes('feat') || title.includes('enhancement'))) { + labelsToAdd.push('major-enhancement'); + } + + // Major bugs + if (title.includes('major') && (title.includes('fix') || title.includes('bug'))) { + labelsToAdd.push('major-bug'); + } + + // Removed/Deprecated + if (title.includes('remove') || title.includes('deprecat')) { + if (title.includes('deprecat')) { + labelsToAdd.push('deprecated'); + } else { + labelsToAdd.push('removed'); + } + } + + // Regular features/enhancements + if ((title.startsWith('feat') || title.includes('feature') || title.includes('enhancement') || + title.startsWith('add ') || title.startsWith('add:') || + branch.startsWith('feature/') || branch.startsWith('feat/')) && + !currentLabels.includes('major-enhancement')) { + labelsToAdd.push('enhancement'); + } + + // Bug fixes + if ((title.startsWith('fix') || title.includes('bugfix') || title.includes('bug fix') || + title.includes('regression') || branch.startsWith('fix/') || branch.startsWith('bugfix/')) && + !currentLabels.includes('major-bug')) { + labelsToAdd.push('bug'); + } + + // Documentation + if (title.startsWith('docs') || title.startsWith('doc:') || + branch.startsWith('docs/') || branch.startsWith('doc/')) { + labelsToAdd.push('documentation'); + } + + // Chore/Maintenance + if (title.startsWith('chore') || title.startsWith('maint') || + title.startsWith('ci:') || title.startsWith('build:') || + branch.startsWith('chore/') || branch.startsWith('maint/')) { + labelsToAdd.push('chore'); + } + + // Refactoring + if (title.startsWith('refactor') || title.includes('refactoring') || + branch.startsWith('refactor/')) { + labelsToAdd.push('refactor'); + } + + // Tests + if (title.startsWith('test') || branch.startsWith('test/')) { + labelsToAdd.push('tests'); + } + + // Security + if (title.includes('security') || title.includes('cve-') || + title.includes('vulnerability') || branch.startsWith('security/')) { + labelsToAdd.push('security'); + } + + // ═══════════════════════════════════════════════════════════════ + // Auto-approve countdown for owner/member PRs + // ═══════════════════════════════════════════════════════════════ + + const countdownLabel = 'merge-in-3-days-without-review'; + const hasCountdownLabel = currentLabels.some(l => l.startsWith('merge-in-')); + + // Add countdown label for trusted authors (non-draft only) + // OWNER = repo owner, MEMBER = org member, COLLABORATOR = added as collaborator + const trustedAssociations = ['OWNER', 'MEMBER', 'COLLABORATOR']; + if (trustedAssociations.includes(authorAssoc) && !isDraft && !hasCountdownLabel) { + labelsToAdd.push(countdownLabel); + console.log(` → Adding auto-approve countdown for ${authorAssoc}`); + } + + // ═══════════════════════════════════════════════════════════════ + // Apply labels + // ═══════════════════════════════════════════════════════════════ + + // Filter out labels that already exist + const newLabels = labelsToAdd.filter(l => !currentLabels.includes(l)); + + if (newLabels.length > 0) { + console.log(` → Adding labels: ${newLabels.join(', ')}`); + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + labels: newLabels + }); + } else { + console.log(' → No new labels to add'); + } diff --git a/.github/workflows/cd.yaml b/.github/workflows/cd.yaml new file mode 100644 index 000000000..0279984d7 --- /dev/null +++ b/.github/workflows/cd.yaml @@ -0,0 +1,15 @@ +# Note: additional setup is required, see https://www.jenkins.io/redirect/continuous-delivery-of-plugins + +name: cd +on: + workflow_dispatch: + check_run: + types: + - completed + +jobs: + maven-cd: + uses: jenkins-infra/github-reusable-workflows/.github/workflows/maven-cd.yml@v1 + secrets: + MAVEN_USERNAME: ${{ secrets.MAVEN_USERNAME }} + MAVEN_TOKEN: ${{ secrets.MAVEN_TOKEN }} diff --git a/.github/workflows/crowdin.yml b/.github/workflows/crowdin.yml new file mode 100644 index 000000000..9a36fa3ff --- /dev/null +++ b/.github/workflows/crowdin.yml @@ -0,0 +1,42 @@ +# This workflow requires additional setup, see: https://jenkins.io/doc/developer/crowdin/ + +name: Crowdin + +on: + schedule: + - cron: '0 */24 * * *' + workflow_dispatch: + +permissions: + actions: write + contents: write + pull-requests: write + +jobs: + synchronize-with-crowdin: + runs-on: ubuntu-latest + if: github.repository_owner == 'jenkinsci' + + steps: + + - name: Checkout + uses: actions/checkout@v7 + + - name: crowdin action + uses: crowdin/github-action@v2.17.0 + with: + upload_translations: false + download_translations: true + skip_untranslated_files: true + push_translations: true + export_only_approved: true + commit_message: 'New Crowdin translations' + create_pull_request: true + pull_request_title: 'Update localization' + pull_request_labels: 'localization' + base_url: 'https://jenkins.crowdin.com' + config: 'crowdin.yml' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + CROWDIN_PROJECT_ID: 26 + CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }} diff --git a/.github/workflows/dependabot-auto-merge.yml b/.github/workflows/dependabot-auto-merge.yml new file mode 100644 index 000000000..33b3e35e8 --- /dev/null +++ b/.github/workflows/dependabot-auto-merge.yml @@ -0,0 +1,24 @@ +# Auto-enable squash merge for Dependabot PRs +# GitHub will automatically merge once all required checks pass +# +# Uses pull_request_target so the workflow runs in the base-branch context +# and the default GITHUB_TOKEN has write permissions (Dependabot-triggered +# pull_request events only expose a read-only token). +name: Dependabot Auto-merge + +on: pull_request_target + +permissions: + contents: write + pull-requests: write + +jobs: + auto-merge: + runs-on: ubuntu-latest + if: github.actor == 'dependabot[bot]' + steps: + - name: Enable auto-merge + run: gh pr merge --auto --squash "$PR_URL" + env: + PR_URL: ${{ github.event.pull_request.html_url }} + GH_TOKEN: ${{ github.token }} diff --git a/.github/workflows/jenkins-security-scan.yml b/.github/workflows/jenkins-security-scan.yml new file mode 100644 index 000000000..2e1301124 --- /dev/null +++ b/.github/workflows/jenkins-security-scan.yml @@ -0,0 +1,24 @@ +# Jenkins Security Scan +# For more information, see: https://www.jenkins.io/doc/developer/security/scan/ + +name: Jenkins Security Scan + +on: + push: + branches: + - master + pull_request: + types: [opened, synchronize, reopened] + workflow_dispatch: + +permissions: + security-events: write + contents: read + actions: read + +jobs: + security-scan: + uses: jenkins-infra/jenkins-security-scan/.github/workflows/jenkins-security-scan.yaml@v2 + with: + java-cache: 'maven' # Optionally enable use of a build dependency cache. Specify 'maven' or 'gradle' as appropriate. + # java-version: 21 # Optionally specify what version of Java to set up for the build, or remove to use a recent default. diff --git a/.github/workflows/pr-labels.yml b/.github/workflows/pr-labels.yml new file mode 100644 index 000000000..7047713bc --- /dev/null +++ b/.github/workflows/pr-labels.yml @@ -0,0 +1,41 @@ +name: PR Labels + +on: + pull_request: + types: [opened, edited, reopened, synchronize] + +permissions: + contents: read + pull-requests: write + issues: write + +jobs: + label: + runs-on: ubuntu-latest + if: ${{ github.event.pull_request.head.repo.full_name == github.repository }} + steps: + - name: Check out + uses: actions/checkout@v7 + + - name: Run labeler + uses: actions/labeler@v7 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + configuration-path: .github/labeler.yml + + required: + name: PR Labels - Required + runs-on: ubuntu-latest + if: ${{ always() && github.event.pull_request.head.repo.full_name == github.repository }} + needs: + - label + steps: + - name: Verify required jobs + shell: bash + run: | + set -euo pipefail + echo "label: ${{ needs.label.result }}" + if [ "${{ needs.label.result }}" != "success" ]; then + echo "::error::PR Labels failed" + exit 1 + fi diff --git a/.github/workflows/rebase-open-prs.yml b/.github/workflows/rebase-open-prs.yml new file mode 100644 index 000000000..8275286f7 --- /dev/null +++ b/.github/workflows/rebase-open-prs.yml @@ -0,0 +1,22 @@ +name: Rebase open PRs + +on: + push: + branches: + - master + +permissions: + contents: write + pull-requests: write + +jobs: + rebase-prs: + runs-on: ubuntu-latest + steps: + - uses: mPokornyETM/rebase-open-prs-action@v1 + env: + # Use PAT to trigger subsequent workflows (security scan, etc.) + # Falls back to github.token if GH_TOKEN secret is not set + GH_TOKEN: ${{ secrets.GH_TOKEN || github.token }} + with: + github-token: ${{ secrets.GH_TOKEN || github.token }} diff --git a/.gitignore b/.gitignore index 6a8ed57db..10d1c2521 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,6 @@ -target/ -bin/ -work/ -.* -.idea *.iml +.DS_Store +/.idea/ +/target/ +/work/ +/jsbundles/ diff --git a/.gitpod.yml b/.gitpod.yml new file mode 100644 index 000000000..a096e0dbd --- /dev/null +++ b/.gitpod.yml @@ -0,0 +1,12 @@ +tasks: + - init: mvn clean verify + +vscode: + extensions: + - bierner.markdown-preview-github-styles + - vscjava.vscode-java-pack + - redhat.java + - vscjava.vscode-java-debug + - vscjava.vscode-java-dependency + - vscjava.vscode-java-test + - vscjava.vscode-maven diff --git a/.mvn/extensions.xml b/.mvn/extensions.xml new file mode 100644 index 000000000..9440b1807 --- /dev/null +++ b/.mvn/extensions.xml @@ -0,0 +1,7 @@ + + + io.jenkins.tools.incrementals + git-changelist-maven-extension + 1.13 + + diff --git a/.mvn/maven.config b/.mvn/maven.config new file mode 100644 index 000000000..f7daf60d0 --- /dev/null +++ b/.mvn/maven.config @@ -0,0 +1,3 @@ +-Pconsume-incrementals +-Pmight-produce-incrementals +-Dchangelist.format=%d.v%s diff --git a/CHANGELOG.old.md b/CHANGELOG.old.md new file mode 100644 index 000000000..946e52daf --- /dev/null +++ b/CHANGELOG.old.md @@ -0,0 +1,88 @@ +# Old Changelog + +This is the old changelog, see +[GitHub Releases](https://github.com/jenkinsci/lockable-resources-plugin/releases) +for recent versions. + +## Release 2.5 (2019-03-25) + +- [Fix security issue](https://jenkins.io/security/advisory/2019-03-25/) + +## Release 2.4 (2019-01-18) + +- [JENKINS-46555](https://issues.jenkins-ci.org/browse/JENKINS-46555) - Fix NPE + on invalid entries. + +## Release 2.3 (2018-06-26) + +- [JENKINS-34433](https://issues.jenkins-ci.org/browse/JENKINS-34433) - Signal + queued Pipeline tasks on unreserve + +- Allow locking multiple resources in Pipeline + +## Release 2.2 (2018-03-06) + +- [JENKINS-40997](https://issues.jenkins-ci.org/browse/JENKINS-40997) - New + configuration option to get the name of the locked resource inside the lock + block (Pipeline). + +- [JENKINS-49734](https://issues.jenkins-ci.org/browse/JENKINS-49734) - + Add a PauseAction to the build when waiting for locking, so Pipeline + representations in the UI are correctly shown. +- [JENKINS-43574](https://issues.jenkins-ci.org/browse/JENKINS-43574) - Fixed + the "empty" resources lock (message: "acquired lock on \[\]") + +## Release 2.1 (2017-11-13) + +- [JENKINS-47235](https://issues.jenkins-ci.org/browse/JENKINS-47235) - + Trim whitespace from resource names. +- [JENKINS-47754](https://issues.jenkins-ci.org/browse/JENKINS-47754) - + Fix broken Freestyle behavior. + +## Release 1.11.2 (2017-03-15) + +- [JENKINS-40368](https://issues.jenkins-ci.org/browse/JENKINS-40368) - Locked + resources are not always freed up on Pipeline hard kill when there + are other pipelines waiting on the Resource + +## Release 1.11.1 (2017-02-20) + +- [JENKINS-40879](https://issues.jenkins-ci.org/browse/JENKINS-40879) - Locked + areas are executed multiple times in parallel + +## Release 1.11 (2016-12-19) + +- [JENKINS-34268](https://issues.jenkins-ci.org/browse/JENKINS-34268) - + lock multiple resources concurrently +- [JENKINS-34273](https://issues.jenkins-ci.org/browse/JENKINS-34273) - + add the number of resources to lock from a given label + +## Release 1.10 (2016-07-12) + +- [JENKINS-36479](https://issues.jenkins-ci.org/browse/JENKINS-36479) - + properly clean up resources locked by hard-killed or deleted while + in progress Pipeline builds. + +## Release 1.9 (2016-06-01) + +- Reserved resources parameter visibility in environment (related to + SECURITY-170) + +## Release 1.8 (2016-04-14) + +- Pipeline compatibility: lock step + +## Release 1.2 (2014-02-05) + +- Manual reservation/un-reservation of resources now require specific + permissions + +## Release 1.1 (2014-02-03) + +- Allow jobs to require a subset of specified resources (the number of required + resources is configurable) +- Allow manual reservation/un-reservation of resources + +## Release 1.0 (2013-12-12) + +- Initial release diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..4cd68725f --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,88 @@ + +# Contributing + +If you want to contribute to this plugin, you probably will need a Jenkins plugin development +environment. This basically means a current version of Java (Java 11 should probably be okay for now) +and [Apache Maven]. See the [Jenkins Plugin Tutorial] for details. +You could also go the [GitPod](https://gitpod.io/#https://github.com/jenkinsci/lockable-resources-plugin) way. + +If you have the proper environment, typing: + +```sh +mvn verify +``` + +should create a plugin as `target/*.hpi`, which you can install in your Jenkins instance. Running + +```sh +mvn hpi:run +``` + +allows you to spin up a test Jenkins instance on [localhost] to test your +local changes before committing. + +[Apache Maven]: https://maven.apache.org/ +[Jenkins Plugin Tutorial]: https://jenkins.io/doc/developer/tutorial/prepare/ +[localhost]: http://localhost:8080/jenkins/ + +## Code Style + +This plugin tries to migrate to [Google Java Code Style], please try to adhere to that style +whenever adding new files or making big changes to existing files. If your IDE doesn't support +this style, you can use the [fmt-maven-plugin], like this: + +```sh + mvn fmt:format -DfilesNamePattern=ChangedFile\.java +``` + +to reformat Java code in the proper style. + +[Google Java Code Style]: https://google.github.io/styleguide/javaguide.html +[fmt-maven-plugin]: https://github.com/coveo/fmt-maven-plugin + +## Code coverage + +Test coverage is a percentage measure of the degree to which the source code of a program is executed when a test is run. A program with high test coverage has more of its source code executed during testing, which suggests it has a lower chance of containing undetected software bugs compared to a program with low test coverage. The best way to improve code coverage is writing of automated tests. + +To get local line-by-line coverage report execute this command + +```sh +mvn -P enable-jacoco clean verify jacoco:report +``` + +The report is then located in *target/site/jacoco/index.html*. + +## License + +The MIT License (MIT) + +- Copyright 2013-2015 6WIND +- Copyright 2016-2018 Antonio Muñiz +- Copyright 2019 TobiX +- Copyright 2017-2022 Jim Klimov + +See [LICENSE](LICENSE.txt) + +## Localization + +[![Crowdin](https://badges.crowdin.net/e/656dcffac5a09ad0fbdedcb430af1904/localized.svg)](https://jenkins.crowdin.com/lockable-resources-plugin) + +Internationalization documentation for Jelly, Java and Groovy can be found [here](https://www.jenkins.io/doc/developer/internationalization/). + +To translate this plugin we recommend to use [Crowdin](https://jenkins.crowdin.com/lockable-resources-plugin). + +Read on [how to use the crowdin web interface](https://www.jenkins.io/doc/developer/crowdin/) to translate plugins. + +When you want to help us, please create a new [feature request](https://github.com/jenkinsci/lockable-resources-plugin/issues/new?assignees=&labels=enhancement&template=2-feature-request.yml) with following content + +Title: +l10n: \ +Description +I would provide new (or update) translations for \ + +We will then add you to the Crowdin project. + +For short translations / updates we can also send you invitation (time limited) + +**Privacy policy notice** +When you start translating via Crowdin service, your browsers will send cookies to Crowdin so that Crowdin can identify translators contributing to the project. You might need to update the privacy policy to reflect this aspect of cookies usage. diff --git a/Jenkinsfile b/Jenkinsfile index a229fa517..7d8a99286 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -1 +1,11 @@ -buildPlugin() +/* + See the documentation for more options: + https://github.com/jenkins-infra/pipeline-library/ +*/ +buildPlugin( + forkCount: '1C', // Run parallel tests on ci.jenkins.io for lower costs, faster feedback + useContainerAgent: true, // Set to `false` if you need to use Docker for containerized tests + configurations: [ + [platform: 'linux', jdk: 25], + [platform: 'windows', jdk: 21], +]) diff --git a/LICENSE.txt b/LICENSE.txt index 79e40d7ee..22ace8034 100644 --- a/LICENSE.txt +++ b/LICENSE.txt @@ -1,6 +1,9 @@ The MIT License -Copyright (c) 2013, 6WIND S.A. All rights reserved. +Copyright 2013-2015, 6WIND S.A. +Copyright 2016-2018, Antonio Muñiz +Copyright 2019-2021, TobiX +Copyright 2022-2026, Martin Pokorny and contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/PULL_REQUEST_TEMPLATE.md b/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 000000000..87cf11c20 --- /dev/null +++ b/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,21 @@ + + +### What does this PR do? + + + +### Testing done + + + +### Checklist + +- [ ] Automated tests added or existing tests cover the change +- [ ] PR title is a clear, imperative-mood changelog entry +- [ ] Breaking changes or upgrade steps are documented below + +### Upgrade guidelines + + + +N/A diff --git a/README.md b/README.md index b8072fbda..239587434 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,517 @@ # Jenkins Lockable Resources Plugin -This plugins allows to define "lockable resources" in the global configuration. -These resources can then be "required" by jobs. If a job requires a resource -which is already locked, it will be put in queue until the resource is released. +[![Jenkins Plugin](https://img.shields.io/jenkins/plugin/v/lockable-resources.svg)](https://plugins.jenkins.io/lockable-resources) +[![GitHub release](https://img.shields.io/github/release/jenkinsci/lockable-resources-plugin.svg?label=release)](https://github.com/jenkinsci/lockable-resources-plugin/releases/latest) +[![Jenkins Plugin Installs](https://img.shields.io/jenkins/plugin/i/lockable-resources.svg?color=blue)](https://plugins.jenkins.io/lockable-resources) +[![Build Status](https://ci.jenkins.io/buildStatus/icon?job=Plugins%2Flockable-resources-plugin%2Fmaster)](https://ci.jenkins.io/job/Plugins/job/lockable-resources-plugin/job/master/) +[![GitHub license](https://img.shields.io/github/license/jenkinsci/lockable-resources-plugin.svg)](https://github.com/jenkinsci/lockable-resources-plugin/blob/master/LICENSE.txt) +[![Maintenance](https://img.shields.io/maintenance/yes/2026.svg)](https://github.com/jenkinsci/lockable-resources-plugin) +[![Crowdin](https://badges.crowdin.net/e/656dcffac5a09ad0fbdedcb430af1904/localized.svg)](https://jenkins.crowdin.com/lockable-resources-plugin) +[![Join the chat at https://gitter.im/jenkinsci/lockable-resources](https://badges.gitter.im/jenkinsci/lockable-resources.svg)](https://gitter.im/jenkinsci/lockable-resources?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) + +This plugin allows defining lockable resources (such as printers, phones, +computers, etc.) that can be used by builds. If a build requires a resource +which is already locked, it will wait for the resource to be free. + + +---- +## Support + +“Open source” does not mean “includes free support” + +You can support the contributor and buy him a coffee. +[![coffee](https://www.buymeacoffee.com/assets/img/custom_images/black_img.png)](https://www.buymeacoffee.com/mpokornyetm) +Every second invested in an open-source project is a second you can't invest in your own family / friends / hobby. +That`s the reason, why supporting the contributors is so important. + +Thx very much for supporting us. + +---- + +## Usage + +### Adding lockable resources + +1. In *Manage Jenkins* > *Lockable Resources* (under the Configuration category) +2. Click *Add Resource* in the top bar + +Each lockable resource has the following properties: + +- **Name** - A mandatory name (not containing spaces!) for this particular resource, i.e. + `DK_Printer_ColorA3_2342` +- **Description** - Optional verbose description of this particular resource, + i.e. `Printers in the Danish Office` +- **Labels** - Optional space-delimited list of Labels (A label can not containing spaces) used to + identify a pool of resources. i.e. `DK_Printers_Office Country:DK device:printer`, + `DK_Printer_Production`, `DK_Printer_Engineering` +- **Reserved by** - Optional reserved / locked cause. If non-empty, + the resource will be unavailable for jobs. i.e. `All printers are currently not available due to maintenance.` + This option is still possible, but we recommend to use the *Manage Jenkins* > *Lockable Resources* page + +A resource is always the one thing that is locked (or free or reserved). +It exists once and has an unique name (if we take the hardware example, this may be `office_printer_14`). +Every resource can have multiple labels (the printer could be labeled `dot-matrix-printer`, `in-office-printer`, `a4-printer`, etc.). +All resources with the same label form a "pool", so if you try to lock an `a4-printer`, one of the resources with the label `a4-printer` will be locked when it is available. +If all resources with the label `a4-printer` are in use, your job waits until one is available. +This is similar to nodes and node labels. + +### Using a resource in a freestyle job + +When configuring the job, select **This build requires lockable resources**. +Please see the help item for each field for details. + +### Using a resource in a pipeline job + +When the `lock` step is used in a Pipeline, if the resource to be locked isn't +already defined in the Jenkins global configuration, an ephemeral resource is +used: These resources only exist as long as any running build is referencing +them. + +Examples: + +#### Acquire lock + +Example for scripted pipeline: + +```groovy +echo 'Starting' +lock('my-resource-name') { + echo 'Do something here that requires unique access to the resource' + // any other build will wait until the one locking the resource leaves this block +} +echo 'Finish' + +``` + +#### Lock with a reason + +You can specify a reason why the resource is being locked. This is displayed +in the lockable resources UI while the resource is locked: + +```groovy +lock(resource: 'staging-server', reason: 'Running integration tests') { + echo 'Deploying to staging' +} +``` + +The reason helps other users understand why a resource is unavailable. + +Example for declarative pipeline: + +```groovy +pipeline { + agent any + + stages { + stage("Build") { + steps { + lock(label: 'printer', quantity: 1, resource : null) { + echo 'printer locked' + } + } + } + } +} +``` + +Setting `quantity` to `null`, `0` or a smaller number, all available resources of that label are locked at once. + +#### Take first position in queue + +```groovy +lock(resource: 'staging-server', inversePrecedence: true) { + node { + servers.deploy 'staging' + } + input message: "Does ${jettyUrl}staging/ look good?" +} +``` + +> It is not allowed to mixed **inversePrecedence** and **priority**. + +start time | job | resource | inversePrecedence +------ |--- |--- |--- +00:01 | j1 | resource1 | false +00:02 | j2 | resource1 | false +00:03 | j3 | resource1 | true +00:04 | j4 | resource1 | false +00:05 | j5 | resource1 | true +00:06 | j6 | resource1 | false + +Resulting lock order: j1 -> j5 -> j3 -> j2 -> j4 -> j6 + +#### lock (queue) priority + +```groovy +lock(resource: 'staging-server', priority: 10) { + node { + servers.deploy 'staging' + } + input message: "Does ${jettyUrl}staging/ look good?" +} +``` + + start time | job | resource | priority + ------ |--- |--- |--- + 00:01 | j1 | resource1 | 0 + 00:02 | j2 | resource1 | + 00:03 | j3 | resource1 | -1 + 00:04 | j4 | resource1 | 10 + 00:05 | j5 | resource1 | -2 + 00:06 | j6 | resource1 | 100 + + Resulting lock order: j1 -> j6 -> j4 -> j2 -> j3 -> j5 + +#### Resolve a variable configured with the resource name and properties + +```groovy +lock(label: 'some_resource', variable: 'LOCKED_RESOURCE') { + echo env.LOCKED_RESOURCE + echo env.LOCKED_RESOURCE0_PROP_ABC +} +``` + +When multiple locks are acquired, each will be assigned to a numbered variable: + +```groovy +lock(label: 'some_resource', variable: 'LOCKED_RESOURCE', quantity: 2) { + // comma separated names of all acquired locks + echo env.LOCKED_RESOURCE + + // first lock + echo env.LOCKED_RESOURCE0 + echo env.LOCKED_RESOURCE0_PROP_ABC + + // second lock + echo env.LOCKED_RESOURCE1 + echo env.LOCKED_RESOURCE1_PROP_ABC +} +``` + +#### Skip executing the block if there is a queue + +```groovy +lock(resource: 'some_resource', skipIfLocked: true) { + echo 'Do something now or never!' +} +``` + +#### Update resource properties + +The `updateLock` step allows pipelines to dynamically manage lockable resources without using the Jenkins UI. + +**Create a new resource:** + +```groovy +updateLock(resource: 'my-resource', createResource: true, setLabels: 'env-test team-a') +``` + +**Modify labels on an existing resource:** + +```groovy +// Replace all labels +updateLock(resource: 'my-resource', setLabels: 'new-label1 new-label2') + +// Add labels (keeps existing) +updateLock(resource: 'my-resource', addLabels: 'additional-label') + +// Remove specific labels +updateLock(resource: 'my-resource', removeLabels: 'old-label') + +// Add and remove in one step +updateLock(resource: 'my-resource', addLabels: 'new', removeLabels: 'old') +``` + +**Set a note on a resource:** + +```groovy +updateLock(resource: 'my-resource', setNote: 'Updated by build #${BUILD_NUMBER}') +``` + +**Delete a resource:** + +```groovy +updateLock(resource: 'my-resource', deleteResource: true) +``` + +> **Note:** Resources cannot be deleted while locked, queued, or reserved. + +Detailed documentation can be found as part of the +[Pipeline Steps](https://jenkins.io/doc/pipeline/steps/lockable-resources/) +documentation. + +### Jenkins label parser allows sophisticated filtering + +The plugin uses the Jenkins-internal label parser for filtering lockable resources. A full list of supported operators and syntax examples can be found in the [official documentation](https://www.jenkins.io/doc/pipeline/steps/workflow-durable-task-step/#node-allocate-node). + +```groovy +lock(label: 'labelA && labelB', variable : 'someVar') { + echo 'labelA && labelB acquired by: ' + env.someVar; +} + +lock(label: 'labelA || labelB', variable : 'someVar') { + echo 'labelA || labelB acquired by: ' + env.someVar; +} + +lock(label: 'labelA || labelB || labelC', variable : 'someVar', quantity : 100) { + echo 'labelA || labelB || labelC acquired by: ' + env.someVar; +} +``` + +#### Multiple resource lock + +```groovy +lock(label: 'label1', extra: [[resource: 'resource1']]) { + echo 'Do something now or never!' +} +echo 'Finish' +``` + +```groovy +lock( + variable: 'var', + extra: [ + [resource: 'resource4'], + [resource: 'resource2'], + [label: 'label1', quantity: 2] + ] +) { + def lockedResources = env.var.split(',').sort() + echo "Resources locked: ${lockedResources}" +} +echo 'Finish' +``` + +More examples are [here](src/doc/examples/readme.md). + +---- + +## Dynamic resource behavior + +When new resources are added to the system, waiting jobs can automatically pick them up: + +- **Pipeline jobs** waiting in the lock step queue are re-evaluated immediately +- **Freestyle jobs** waiting in the Jenkins build queue are re-evaluated via queue maintenance + +This allows you to dynamically add resources to your resource pool without requiring waiting jobs to be restarted. + +### Example + +If a job is waiting for a resource with label `printer` and all existing printer resources are locked, adding a new resource with the label `printer` will allow the waiting job to acquire it immediately. + +### Limitations + +- **Modifying labels on existing resources does NOT trigger re-evaluation.** Only adding new resources triggers waiting jobs to re-evaluate. If you change labels on an existing resource, you can manually call `LockableResourcesManager.get().refreshQueue()` via Script Console to notify waiting jobs. +- **Removing resources** does not affect waiting jobs (they continue waiting for other resources with matching criteria). + +---- + +## Node mirroring + +Lockable resources plugin allow to mirror nodes (agents) into lockable resources. This eliminate effort by re-creating resources on every node change. + +That means when you create new node, it will be also created new lockable-resource with the same name. When the node has been deleted, lockable-resource will be deleted too. + +Following properties are mirrored: + +- name. +- labels. Please note, that labels still contains node-name self. +- description. + +To allow this behavior start jenkins with option `-Dorg.jenkins.plugins.lockableresources.ENABLE_NODE_MIRROR=true` or run this groovy code. + +```groovy +System.setProperty("org.jenkins.plugins.lockableresources.ENABLE_NODE_MIRROR", "true"); +``` + +> *Note:* When the node has been deleted, during the lockable-resource is locked / reserved / queued, then the lockable-resource will be NOT deleted. + +---- + +## Improve performance + +To be safe thread over all jobs and resources, need to be all operations synchronized. +This might lead to slow down your jobs. The jenkins self, has low CPU usage, but your jobs are very slow. Why? + +The most time are spend to write the lockable-resources states into local file system. This is important to get the correct state after Jenkins reboots. + +To eliminate this saving time has been added a property *DISABLE_SAVE*. + ++ The best way is to use it with JCaC plugin. So you are sure, you have still correct +resources on Jenkins start. ++ When you set pipeline durability level to *PERFORMANCE_OPTIMIZED*, it makes also sense to set this property to true. + +> *Note:* Keep in mind, that you will lost all your manual changes! + +> *Note:* This option is experimental. It has been tested in many scenarios, but no body know. + +To allow this behavior start jenkins with option `-Dorg.jenkins.plugins.lockableresources.DISABLE_SAVE=true` or run this groovy code. + +```groovy +System.setProperty("org.jenkins.plugins.lockableresources.DISABLE_SAVE", "true"); +``` + +## Detailed lock cause + +Tle plugin step lock() will inform you in the build log detailed block cause. The size of cause depends on count of ordered resources and size of current queue. To eliminate big unreadable logs we limited the size. To see all cause change the properties as follow: + +```groovy +System.setProperty("org.jenkins.plugins.lockableresources.PRINT_BLOCKED_RESOURCE", "-1"); +System.setProperty("org.jenkins.plugins.lockableresources.PRINT_QUEUE_INFO", "-1"); +``` + +*PRINT_BLOCKED_RESOURCE* means how many of ordered resources are printed. Per default 2. +*PRINT_QUEUE_INFO* how many queue items are printed. Per default 2. + + 0 means disabled + -1 means all / unlimited. + +## Configuration as Code + +This plugin can be configured via +[Configuration-as-Code](https://github.com/jenkinsci/configuration-as-code-plugin). + +### Example configuration + +```yml +unclassified: + lockableResourcesManager: + declaredResources: + - name: "S7_1200_1" + description: "S7 PLC model 1200" + labels: "plc:S7 model:1200" + - name: "S7_1200_2" + labels: "plc:S7 model:1200" + - name: "Resource-with-properties" + properties: + - name: "Property-A" + value: "Value" +``` + +Properties *description*, *labels* and *properties* are optional. + +Fields like *reservedBy*, *reservedTimestamp* or *note* are not supported, they will be ignored. + +---- + +## lockable-resources overview + +The page *Manage Jenkins* > *Lockable Resources* (also accessible at `/lockable-resources/`) provides a management dashboard for all lockable resources. When accessed from Manage Jenkins, it renders with the sidebar layout; when accessed directly via the URL, it renders as a standalone page. + +The page has four tabs: **Overview**, **Resources**, **Labels**, and **Queue**. + +### Overview + +Provides at-a-glance stats including a resource donut chart (locked/reserved/free), utilization percentages, top labels, and queue metrics (longest wait, most contended resource). Cards are clickable to jump to the relevant tab. + +### Resources + +Provides a status overview of all resources and actions to change resource status. + +Resources can be created, edited, and deleted directly from this tab using the **Add Resource** button and per-row edit/delete actions (requires CONFIGURE permission). Bulk actions (reserve, unreserve, unlock, steal) are available via multi-select checkboxes. + +Name | Permission | Description +-----|------------|------------ +Add Resource | CONFIGURE | Creates a new lockable resource. +Edit (Gear Icon) | CONFIGURE | Edits an existing resource (name, description, labels, properties). +Delete | CONFIGURE | Deletes a resource (only if not locked, reserved, or queued). +Reserve | RESERVE | Reserves an available resource for currently logged user indefinitely (until that person, or some explicit scripted action, decides to release the resource). +Unreserve | RESERVE | Un-reserves a resource that may be reserved by some person already. The user can unreserve only own resource. Administrator can unreserve any resource. +Unlock | UNLOCK | Unlocks a resource that may be or not be locked by some job (or reserved by some user) already. +Steal lock | STEAL | Reserves a resource that may be or not be locked by some job (or reserved by some user) already. Giving it away to currently logged user indefinitely (until that person, or some explicit scripted action, later decides to release the resource). +Reassign | STEAL | Reserves a resource that may be or not be reserved by some person already. Giving it away to currently logged user indefinitely (until that person, or some explicit scripted action, decides to release the resource). +Reset | UNLOCK | Reset a resource that may be reserved, locked or queued. +Note | RESERVE | Add or edit resource note. + +### Labels + +Provides an overview over all lockable-resources labels. + +> *Note:* Please keep in mind, that lockable-resource-label is not the same as node-label! + +### Queue + +Provides an overview of currently queued requests with server-side pagination for performance with large queues. +A request is queued by the pipeline step `lock()`. When the requested resource(s) is currently in use (not free), then any new request for this resource will be added into the queue. + +A resource may be requested by: + +- name, such as in `lock('ABC') { ... }` +- label, such as in `lock(label : 'COFFEE_MACHINE')` + +> *Note:* Please keep in mind that groovy expressions are currently supported only in free-style jobs. Free-style jobs do not update this queue and therefore can not be shown in this view. + +> *Note:* An empty value in the column 'Requested at' means that this build has been started in an older plugin version - [1117.v157231b_03882](https://github.com/jenkinsci/lockable-resources-plugin/releases/tag/1117.v157231b_03882) and early. In this case we cannot recognize the timestamp. + +### Permissions + +The plugin defines its own permission group under *Manage Jenkins* > *Security* > *Authorization*: + +Permission | Implied by | Description +-----------|------------|------------ +View | Jenkins.READ | See the lockable resources page and resource status. +Configure | Jenkins.ADMINISTER | Create, edit, and delete resources. +Unlock | Jenkins.ADMINISTER | Manually unlock a resource. +Reserve | Jenkins.ADMINISTER | Reserve or unreserve a resource. +Steal | Jenkins.ADMINISTER | Steal a lock from another build. +Queue | Jenkins.ADMINISTER | Reorder the lock wait queue. + +All mutating permissions default to `ADMINISTER`, preserving existing behavior. Admins can delegate individual permissions to specific roles without granting full admin access. + +---- + +## Upgrading from 1102.vde5663d777cf + +Due an [issue](https://github.com/jenkinsci/lockable-resources-plugin/issues/434) **is not possible anymore to read resource-labels** from the config file org.jenkins.plugins.lockableresources.LockableResourcesManager.xml, **which is generated in the release** [1102.vde5663d777cf](https://github.com/jenkinsci/lockable-resources-plugin/releases/tag/1102.vde5663d777cf) + +This issue does not **effect** instances configured by [Configuration-as-Code](https://github.com/jenkinsci/configuration-as-code-plugin) plugin. + +A possible solution is to remove the `` tags from your `org.jenkins.plugins.lockableresources.LockableResourcesManager.xml` config file manually, before you upgrade to new version (Keep in mind that a backup is still good idea). + +Example: + +change this one + +```xml + + tests-integration-installation + +``` + +to + +```xml + + tests-integration-installation + +``` + +---- + +## Changelog + +- See [GitHub Releases](https://github.com/jenkinsci/lockable-resources-plugin/releases) + for recent versions. +- See the [old changelog](CHANGELOG.old.md) for versions 2.5 and older. + +---- + +## Report an Issue + +Please report issues and enhancements through the [Jenkins issue tracker in GitHub](https://github.com/jenkinsci/lockable-resources-plugin/issues/new/choose) + +---- + +## Contributing + +Contributions are welcome, please +refer to the separate [CONTRIBUTING](CONTRIBUTING.md) document +for details on how to proceed! +Join [Gitter channel](https://gitter.im/jenkinsci/lockable-resources) to discuss your ideas with the community. + +---- + +## License + +All source code is licensed under the MIT license. +See [LICENSE](LICENSE.txt) diff --git a/check_licenses.py b/check_licenses.py deleted file mode 100644 index f802aa2f7..000000000 --- a/check_licenses.py +++ /dev/null @@ -1,85 +0,0 @@ -#!/bin/env python - -import os, sys, glob - -JAVA_LICENSE = """\ -/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * - * Copyright (c) 2013, 6WIND S.A. All rights reserved. * - * * - * This file is part of the Jenkins Lockable Resources Plugin and is * - * published under the MIT license. * - * * - * See the "LICENSE.txt" file for more information. * - * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ -""" -XML_LICENSE = """\ - -""" - -LICENSED_FILES = { - '.java': JAVA_LICENSE, - '.jelly': XML_LICENSE, - '.xml': XML_LICENSE, -} - -def check_file(file, do_modify): - _, ext = os.path.splitext(file) - missing = 0 - inserted = 0 - if ext in LICENSED_FILES.keys(): - license = LICENSED_FILES[ext] - - with open(file, 'r') as fd: - buffer = fd.read() - - if license not in buffer: - missing = 1 - if do_modify: - with open(file, 'w') as fd: - if buffer.startswith('#!') or buffer.startswith(' 1 and sys.argv[1] == "--modify" - - missing = 0 - inserted = 0 - - for pom in glob.glob('pom.xml'): - miss, ins = check_file(pom, do_modify) - missing += miss - inserted += ins - for source_folder in glob.glob('src'): - for root, dirs, files in os.walk(source_folder): - for file in files: - miss, ins = check_file(os.path.join(root, file), do_modify) - missing += miss - inserted += ins - - print - print missing, "license headers missing.", inserted, "inserted" - - -if __name__ == "__main__": - main() - diff --git a/crowdin.yml b/crowdin.yml new file mode 100644 index 000000000..cb7b6a84b --- /dev/null +++ b/crowdin.yml @@ -0,0 +1,8 @@ +project_id_env: CROWDIN_PROJECT_ID +api_token_env: CROWDIN_PERSONAL_TOKEN +files: + - source: /src/main/resources/org/jenkins/plugins/lockableresources/**/*.properties + ignore_settings: 1 + ignore: + - /src/main/resources/org/jenkins/plugins/lockableresources/**/%file_name%_%two_letters_code%.properties + translation: /src/main/resources/org/jenkins/plugins/lockableresources/**/%file_name%_%two_letters_code%.properties diff --git a/pom.xml b/pom.xml index b477f183b..aa15a26f9 100644 --- a/pom.xml +++ b/pom.xml @@ -1,175 +1,196 @@ - + - 4.0.0 - - org.jenkins-ci.plugins - plugin - 2.24 - - + 4.0.0 - org.6wind.jenkins - lockable-resources - 2.6-SNAPSHOT - hpi - Lockable Resources plugin - - This plugin allows to define lockable resources (such as printers, phones, - computers) that can be used by builds. If a build requires an external - resource which is already locked, it will wait for the resource to be free. - - https://wiki.jenkins-ci.org/display/JENKINS/Lockable+Resources+Plugin + + org.jenkins-ci.plugins + plugin + 6.2211.v27f680c93c53 + + - - - 1.14 - 1.609.1 - - false - + org.6wind.jenkins + lockable-resources + ${changelist} + hpi - - - MIT - http://www.opensource.org/licenses/mit-license.php - - + Lockable Resources plugin + This plugin allows to define lockable resources (such as printers, phones, + computers) that can be used by builds. If a build requires an external + resource which is already locked, it will wait for the resource to be free. + https://github.com/jenkinsci/${project.artifactId}-plugin + 2013 + + + MIT + https://opensource.org/licenses/MIT + + - - - robin-jarry - Robin Jarry - robin.jarry@6wind.com - - developer - maintainer - - CET - - - amuniz - Antonio Muñiz - amuniz@cloudbees.com - - developer - maintainer - - CET - - + + + TobiX + Tobias Gruetzmacher + tobias-git@23.gs + + + amuniz + Antonio Muñiz + amuniz@cloudbees.com + + + mPokornyETM + Martin Pokorny + martin.pokorny@etm.at + + - - - org.jenkins-ci.plugins - mailer - 1.5 - - - org.jenkins-ci.plugins.workflow - workflow-step-api - ${workflow.version} - - - org.jenkins-ci.plugins.workflow - workflow-support - ${workflow.version} - - - org.jenkins-ci.plugins - matrix-project - 1.4 - - - org.jenkins-ci.plugins - script-security - 1.26 - - - com.infradna.tool - bridge-method-annotation - 1.14 - true - + + scm:git:https://github.com/${gitHubRepo}.git + scm:git:git@github.com:${gitHubRepo}.git + ${scmTag} + https://github.com/${gitHubRepo} + - - - org.jenkins-ci.plugins.workflow - workflow-aggregator - ${workflow.version} - test - - - org.jenkins-ci.plugins.workflow - workflow-support - ${workflow.version} - tests - test - - - org.jenkins-ci.modules - sshd - 1.6 - test - - - org.jenkins-ci.plugins - junit - 1.13 - test - - + + 999999-SNAPSHOT + + 2.541 + ${jenkins.baseline}.3 + jenkinsci/${project.artifactId}-plugin + Max + Low + false + false + 2.1.8 + + - - - - org.jenkins-ci.tools - maven-hpi-plugin - - - FINE - - 2.0 - - - - maven-release-plugin - 2.5.2 - - false - - - - + + + + io.jenkins.tools.bom + bom-${jenkins.baseline}.x + 6783.v88c6c30f4b_db_ + pom + import + + + - - - repo.jenkins-ci.org - http://repo.jenkins-ci.org/public/ - - + + + io.jenkins.plugins + caffeine-api + + + org.jenkins-ci.plugins + credentials + + + org.jenkins-ci.plugins + mailer + + + org.jenkins-ci.plugins + matrix-project + true + + + org.jenkins-ci.plugins + script-security + + + org.jenkins-ci.plugins + structs + + + org.jenkins-ci.plugins + variant + + + org.jenkins-ci.plugins.workflow + workflow-support + - - - repo.jenkins-ci.org - http://repo.jenkins-ci.org/public/ - - + + + io.jenkins + configuration-as-code + test + + + io.jenkins.configuration-as-code + test-harness + test + + + org.jenkins-ci.plugins.workflow + workflow-basic-steps + test + + + org.jenkins-ci.plugins.workflow + workflow-cps + test + + + org.jenkins-ci.plugins.workflow + workflow-job + test + + + org.jenkins-ci.plugins.workflow + workflow-support + tests + test + + + org.jenkinsci.plugins + pipeline-model-definition + test + + + org.mockito + mockito-junit-jupiter + test + + + + uk.org.webcompere + system-stubs-jupiter + ${system-stubs.version} + + test + + - - scm:git:https://github.com/jenkinsci/lockable-resources-plugin.git - scm:git:git@github.com:jenkinsci/lockable-resources-plugin.git - https://github.com/jenkinsci/lockable-resources-plugin - HEAD - + + + repo.jenkins-ci.org + https://repo.jenkins-ci.org/public/ + + + + + repo.jenkins-ci.org + https://repo.jenkins-ci.org/public/ + + + + + + org.jenkins-ci.tools + maven-hpi-plugin + + + FINE + + 2.0 + org.jenkins.plugins.lockableresources + + + + diff --git a/src/doc/examples/avoiding-executor-starvation.md b/src/doc/examples/avoiding-executor-starvation.md new file mode 100644 index 000000000..65a5fdcd6 --- /dev/null +++ b/src/doc/examples/avoiding-executor-starvation.md @@ -0,0 +1,141 @@ +# Avoiding Executor Starvation + +When a pipeline uses `agent any` (or a specific label) at the top level together +with `options { lock(...) }`, every build allocates an executor **before** +trying to acquire the lock. If the resource is busy the build sits on the +executor doing nothing — preventing other jobs from using it. This is called +**executor starvation**. + +## The problem + +```groovy +pipeline { + agent { + label 'some-label' + } + options { + lock('end-to-end-test-resource') + } + stages { + stage('Test') { + steps { + echo 'Running end-to-end tests...' + } + } + } +} +``` + +With this layout, if three builds of the same job are queued and there is only +one `end-to-end-test-resource`, all three grab an executor yet only one can +proceed. The other two hold their executors hostage while they wait for the +lock, blocking any other job that needs to run on those executors. + +## The solution — `agent none` + stage-level agent + +Move the `agent` directive **inside the stage** that needs the lock: + +```groovy +pipeline { + agent none // no executor allocated up front + stages { + stage('Test') { + options { + lock('end-to-end-test-resource') // acquire lock first … + } + agent { + label 'some-label' // … then allocate an executor + } + steps { + echo 'Running end-to-end tests...' + } + } + } +} +``` + +Now a build waiting for the lock does **not** occupy an executor. Once the lock +is acquired the stage allocates the agent and runs. Other jobs can use the +executors in the meantime. + +## Preparation stages that don't need the lock + +If your build has work that can run without the resource, split it into a +separate stage so the lock is held only when necessary: + +```groovy +pipeline { + agent none + stages { + stage('Build') { + agent any + steps { + echo 'Compiling — no lock needed' + } + } + stage('Deploy') { + options { + lock resource: 'deploy-target' + } + agent any + steps { + echo 'Deploying — lock held' + } + } + } +} +``` + +## Multiple stages under one lock + +If several stages need the same resource, wrap them in a parent stage: + +```groovy +pipeline { + agent none + stages { + stage('Deploy and Test') { + options { + lock resource: 'test-environment' + } + agent any + stages { + stage('Deploy') { + steps { + echo 'Deploying...' + } + } + stage('Integration Test') { + steps { + echo 'Testing...' + } + } + } + } + } +} +``` + +The lock is acquired once for the parent stage and released after the last +nested stage completes. No executor is consumed while waiting. + +## Scripted pipeline equivalent + +In scripted pipelines the same principle applies — acquire the lock **before** +allocating a node: + +```groovy +lock('end-to-end-test-resource') { + node('some-label') { + echo 'Running with lock and node' + } +} +``` + +See also [Node dependent resources](lock-nodes.md) for more scripted examples. + +## See also + +- [Lock specific stages](lock-specific-stages.md) +- [Locking multiple stages in declarative pipeline](locking-multiple-stages-in-declarative-pipeline.md) +- [Node dependent resources](lock-nodes.md) diff --git a/src/doc/examples/dynamic-resource-pool-expansion.md b/src/doc/examples/dynamic-resource-pool-expansion.md new file mode 100644 index 000000000..70052bff0 --- /dev/null +++ b/src/doc/examples/dynamic-resource-pool-expansion.md @@ -0,0 +1,75 @@ +# Dynamic Resource Pool Expansion + +This example demonstrates how waiting jobs can automatically acquire newly added resources. + +## Use Case + +You have a pool of resources (e.g., test devices) labeled `test-device`. All devices are currently in use by running jobs. A new job starts and waits for a `test-device`. When you add a new device to the pool, the waiting job should automatically acquire it without needing to be restarted. + +## Pipeline Example + +### Job waiting for a resource + +```groovy +pipeline { + agent any + stages { + stage('Acquire Device') { + steps { + lock(label: 'test-device', quantity: 1, variable: 'DEVICE') { + echo "Acquired device: ${env.DEVICE}" + // Use the device + sh 'run-tests.sh' + } + } + } + } +} +``` + +### Adding a new resource via pipeline step + +While the job is waiting, you can add a new resource via a management job or the Script Console using the `updateLock` step: + +```groovy +// In a pipeline job +updateLock(resource: 'new-test-device-5', addLabels: 'test-device') +``` + +Or via Script Console: + +```groovy +import org.jenkins.plugins.lockableresources.LockableResourcesManager + +def manager = LockableResourcesManager.get() +manager.createResourceWithLabel('new-test-device-5', 'test-device') +``` + +The waiting job will automatically acquire `new-test-device-5` once it is added. + +## Freestyle Job Example + +For freestyle jobs configured with **Required Resources** (label: `test-device`), the same behavior applies. When a new resource with the matching label is added, the Jenkins queue is notified and the waiting freestyle job will be dispatched. + +## Limitations + +> **Important:** Modifying labels on existing resources does NOT trigger re-evaluation. + +Only **adding new resources** triggers waiting jobs to re-evaluate their resource requirements. If you: +- Change labels on an existing resource (e.g., add `test-device` label to an existing resource) +- The waiting jobs will **not** be notified + +To work around this limitation, you can manually trigger queue refresh via Script Console: + +```groovy +import org.jenkins.plugins.lockableresources.LockableResourcesManager + +LockableResourcesManager.get().refreshQueue() +``` + +This will invalidate the cached candidates and notify both pipeline and freestyle jobs to re-evaluate available resources. + +## Related + +- [JENKINS-46744](https://issues.jenkins.io/browse/JENKINS-46744) - Original issue requesting this behavior +- [GitHub #892](https://github.com/jenkinsci/lockable-resources-plugin/issues/892) - Implementation tracking issue diff --git a/src/doc/examples/lock-nodes.md b/src/doc/examples/lock-nodes.md new file mode 100644 index 000000000..4394864b8 --- /dev/null +++ b/src/doc/examples/lock-nodes.md @@ -0,0 +1,185 @@ +# Examples + +## Node dependent resources + +Locking a resource that depends on a specific node can be very helpful in many cases. +That means a job must pick a target node that has the requested resource available. + +```groovy +// allocate node +node('some-build-node') { + // Lock resource named *whatever-resource-some-build-node* + lock("whatever-resource-${env.NODE_NAME}") { + echo "Running on node ${env.NODE_NAME} with locked resource ${env.LOCKED_RESOURCE}" + } +} +``` + +But much more useful is lock node first. + +```groovy +// Lock resource named *some-build-node* +lock('some-build-node') { + // allocate node + node(env.LOCKED_RESOURCE) { + + echo "I am on node ${env.NODE_NAME} and locked resource ${env.LOCKED_RESOURCE}" + } +} +``` + +Let explain in more complex use case. + +*Request:* +Our job tests server-client integration. That means we need 2 nodes (1 server and 1 client). +On every node must be test sources up-to-date. +Tests are running only on client side. + +*Solution:* +Create 2 nodes: + +- node-server +- node-client + +and execute it parallel like this: + +```groovy +Map stages = [:]; +stages['server'] = { + node('node-server') { + prepareTests() + startServer() + } +} +stages['client'] = { + node('node-client') { + prepareTests() + startClientTest() + } +} +// execute all prepare stages synchronous +parallel stages + +// Prepare tests on node +void prepareTests() { + checkout([$class: 'GitSCM', + branches: [[name: 'master']] + ]) +} +// Start server +void startServer() { + echo 'Server will be started in few seconds' + sh 'mvn clean hpi:run' + echo 'Server is done' +} +// Start client +void startClientTest() { + sleep 20 + sh 'mvn clean verify' +} +``` + +It looks pretty fine and easy, but !!!. + +Executing all steps parallel might leads to timing issues, because checkout on server-node might takes much longer then on client-node. This is serious issue because the client starts before the server and can not connect to server. + +The solution is to synchronized parallel stages like this. + +```groovy +Map prepareStages = [:]; +prepareStages['server'] = { + node('node-server') { + prepareTests() + } +} +prepareStages['client'] = { + node('node-client') { + startServer() + } +} +// execute all prepare stages synchronous +parallel prepareStages + +Map testStages = [:] +testStages['server'] = { + node('node-server') { + prepareTests(); + } +} +testStages['client'] = { + node('node-client') { + sleep 20 + startClientTest(); + } +} + +// execute all test stages at the same time +testStages.failFast = true +parallel testStages + +... +``` + +Ok we solve the timing issue, but what is wrong here? + +When the step *parallel prepareStages* is done then are on both nodes executors free. At this moment +it might happen, that some other job allocate one of the nodes. This will leads to more side effects, like: + +- no body can grant, that currently checked out workspace will be untouched +- no body can grant how long will be the node allocated +- ... and many others + +Instead, we lock both nodes with a single call to `lock`. + +Create two resources: +name | Labels | +---------------|--------| +nodes-server-1 | server-node | +nodes-client-1 | client-node | + + +```groovy +lock(variable: 'locked_resources', + extra: [ + [label: 'server-node', quantity: 1], + [label: 'client-node', quantity: 1] + ) { + final String serverNodeName = env.LOCKED_RESOURCE0; + final String clientNodeName = env.LOCKED_RESOURCE1; + Map prepareStages = [:]; + prepareStages['server'] = { + node(serverNodeName) { + prepareTests() + } + } + prepareStages['client'] = { + node(clientNodeName) { + startServer() + } + } + // execute all prepare stages synchronous + parallel prepareStages + + Map testStages = [:] + testStages['server'] = { + node(serverNodeName) { + prepareTests(); + } + } + testStages['client'] = { + node(clientNodeName) { + sleep 20 + startClientTest(); + } + } + + // execute all test stages at the same time + testStages.failFast = true + parallel testStages + +} + +... +``` + +Keep in mind, that `lock()` only helps when locks are consistently requested for resources. diff --git a/src/doc/examples/lock-specific-stages.md b/src/doc/examples/lock-specific-stages.md new file mode 100644 index 000000000..7cb85b046 --- /dev/null +++ b/src/doc/examples/lock-specific-stages.md @@ -0,0 +1,94 @@ +# Lock Specific Stages + +For long-running builds where a resource is only needed for part of the pipeline, +you can lock individual stages instead of the entire build. This allows other +jobs to use the resource while your build performs tasks that don't require it. + +## Lock a single stage + +Use the `options` block to lock a resource for just one stage: + +```groovy +pipeline { + agent any + stages { + stage('Build') { + steps { + echo 'Building for 27 minutes...' + echo 'Resource foo is not locked' + } + } + stage('Deploy') { + options { + lock(label: 'foo', quantity: 1) + } + steps { + echo 'Deploying for 3 minutes...' + echo 'Resource foo is locked' + } + } + stage('Verify') { + steps { + echo 'Verifying...' + echo 'Resource foo is not locked anymore' + } + } + } +} +``` + +## Lock multiple consecutive stages + +To lock a resource across multiple stages, nest them inside a parent stage +with the lock option: + +```groovy +pipeline { + agent any + stages { + stage('Build') { + steps { + echo 'Building...' + echo 'Resource foo is not locked' + } + } + stage('Deploy and Test') { + options { + lock(label: 'foo', quantity: 1) + } + stages { + stage('Deploy') { + steps { + echo 'Deploying...' + echo 'Resource foo is locked' + } + } + stage('Integration Test') { + steps { + echo 'Testing...' + echo 'Resource foo is still locked' + } + } + } + } + stage('Cleanup') { + steps { + echo 'Cleaning up...' + echo 'Resource foo is not locked anymore' + } + } + } +} +``` + +## When to use this pattern + +This pattern is useful when: + +- Your build has a long preparation phase that doesn't need the locked resource +- You want to maximize resource utilization across multiple jobs +- Only specific stages (like deployment or testing) require exclusive access + +## See also + +- [Locking multiple stages in declarative pipeline](locking-multiple-stages-in-declarative-pipeline.md) diff --git a/src/doc/examples/lock-with-timeout.md b/src/doc/examples/lock-with-timeout.md new file mode 100644 index 000000000..ce07f5dff --- /dev/null +++ b/src/doc/examples/lock-with-timeout.md @@ -0,0 +1,77 @@ +## Lock with allocation timeout + +By default, the `lock` step waits indefinitely until the requested resource becomes available. +With `timeoutForAllocateResource` you can set a maximum wait time — if the resource is not +acquired within that period, the build fails immediately instead of blocking the queue forever. + +This is useful when: +- You prefer a fast failure over an indefinitely blocked pipeline +- You want to detect resource starvation early +- Your CI/CD has SLAs that cap how long a job may wait + +### Pipeline (scripted) + +```groovy +node { + // Wait up to 5 minutes for the resource, then fail + lock(resource: 'my-printer', timeoutForAllocateResource: 5, timeoutUnit: 'MINUTES') { + echo "Printer locked, printing ..." + } +} +``` + +### Pipeline (declarative) + +```groovy +pipeline { + agent any + stages { + stage('Deploy') { + options { + lock(resource: 'staging-env', timeoutForAllocateResource: 10, timeoutUnit: 'MINUTES') + } + steps { + echo "Deploying to staging ..." + } + } + } +} +``` + +### Label-based locking with timeout + +```groovy +pipeline { + agent any + stages { + stage('Test') { + steps { + lock(label: 'phone', quantity: 1, variable: 'PHONE', + timeoutForAllocateResource: 2, timeoutUnit: 'MINUTES') { + echo "Running tests on ${env.PHONE}" + } + } + } + } +} +``` + +### Freestyle jobs + +In a freestyle job configuration, go to **This build requires lockable resources** and set: +- **Lock wait timeout**: the maximum time to wait (e.g. `5`) +- **Timeout unit**: `SECONDS`, `MINUTES`, or `HOURS` + +If the resource is not available within the configured timeout, the build is automatically +removed from the Jenkins queue. + +### Timeout values + +| `timeoutUnit` | Description | +|---------------|-------------| +| `SECONDS` | Timeout in seconds | +| `MINUTES` | Timeout in minutes (default) | +| `HOURS` | Timeout in hours | + +Setting `timeoutForAllocateResource: 0` (the default) disables the timeout — the build +waits indefinitely, which preserves the original behaviour. diff --git a/src/doc/examples/locking-multiple-stages-in-declarative-pipeline.md b/src/doc/examples/locking-multiple-stages-in-declarative-pipeline.md new file mode 100644 index 000000000..2c36706f5 --- /dev/null +++ b/src/doc/examples/locking-multiple-stages-in-declarative-pipeline.md @@ -0,0 +1,33 @@ +# Locking multiple stages in declarative pipeline + +You can lock the entire job in the options block of the pipeline: + + +```groovy +pipeline { +options { + lock 'lockable-resource' + } + + agent any + + stages { + stage('Build') { + steps { + sh 'make' + } + } + stage('Test'){ + steps { + sh 'make check' + junit 'reports/**/*.xml' + } + } + stage('Deploy') { + steps { + sh 'make publish' + } + } + } +} +``` diff --git a/src/doc/examples/locking-random-free-resource.md b/src/doc/examples/locking-random-free-resource.md new file mode 100644 index 000000000..c98636bbd --- /dev/null +++ b/src/doc/examples/locking-random-free-resource.md @@ -0,0 +1,36 @@ +## Locking a random free resource + +In same cases, you want a random resource to be locked instead of always choosing the first one. For example, +if resources like accounts could get rate limited if they are used too often by your pipelines. + +With the following resources created: + +| Name | Label | +|-----------|---------| +| account_1 | account | +| account_2 | account | + +You can pick a single random available resource like in the following declarative pipeline: + +```groovy +pipeline { + agent any + stages { + stage("Build") { + steps { + lock(label: "account", resourceSelectStrategy: 'random', resource: null, quantity: 1, variable: "account") { + echo "Using account " + env.account + // do your thing using the resource + } + } + } + } +} +``` + +The `quantity` can be changed to lock any amount of available resources with the given label. Not specifying the +quantity will lock all resources, but still randomize the order of resources in the numbered environment variable. + +Not specifying `resourceSelectStrategy`, will fall back to the default behaviour of locking resources according to +their order in the lockable resources list. You can also explicitly configure the default strategy +with `resourceSelectStrategy: 'sequential'`. diff --git a/src/doc/examples/readme.md b/src/doc/examples/readme.md new file mode 100644 index 000000000..47730b97b --- /dev/null +++ b/src/doc/examples/readme.md @@ -0,0 +1,19 @@ +# Examples + +Examples of lockable resources include: + +If you have an example to share, please create a [new documentation issue](https://github.com/jenkinsci/lockable-resources-plugin/issues/new?assignees=&labels=documentation&template=3-documentation.yml) and provide additional examples as a [pull request](https://github.com/jenkinsci/lockable-resources-plugin/pulls) to the repository. +If you have a question, please open a [GitHub issue](https://github.com/jenkinsci/lockable-resources-plugin/issues/new/choose) with your question. + +- [Avoiding executor starvation](avoiding-executor-starvation.md) +- [Node depended resources](lock-nodes.md) +- [Lock specific stages](lock-specific-stages.md) +- [Locking multiple stages in declarative pipeline](locking-multiple-stages-in-declarative-pipeline.md) +- [Locking a random free resource](locking-random-free-resource.md) +- [Resource properties](resource-properties.md) +- [Scripted vs declarative pipeline](scripted-vs-declarative-pipeline.md) +- [Timeout inside lock](timeout-inside-lock.md) +- [Dynamic resource pool expansion](dynamic-resource-pool-expansion.md) +- [Lock with allocation timeout](lock-with-timeout.md) +- [Remote Lock REST API (curl examples)](remote-api-curl.md) +- [Remote lock from Jenkins Pipeline (local gate + remote lock)](remote-lock-pipeline-pattern.md) diff --git a/src/doc/examples/remote-api-curl.md b/src/doc/examples/remote-api-curl.md new file mode 100644 index 000000000..15e1ebce7 --- /dev/null +++ b/src/doc/examples/remote-api-curl.md @@ -0,0 +1,293 @@ +## Remote Lock API — Shell/curl examples + +The Remote Lock REST API lets external applications or non-Jenkins scripts acquire a +lockable resource on a Jenkins controller without a Pipeline step. + +### Prerequisites + +| Item | Where to configure | +|---|---| +| **Remote API enabled** | Manage Jenkins → Configure System → Lockable Resources → Enable Remote API | +| **Resource exposed** | The resource must carry one of the labels listed in *Expose Label* | +| **User with API token** | The user must have **Lockable Resources / RemoteUse** permission | +| **API token** | User → Security → API Token | + +All examples below use: + +``` +JENKINS=http://localhost:8080/jenkins # root URL of the owning controller +USER=lrm # Jenkins user with RemoteUse permission +TOKEN= # API token (NOT account password) +RESOURCE=r1 # resource name to lock +``` + +--- + +### Basic flow: lock → wait → use → release + +```bash +#!/usr/bin/env bash +set -euo pipefail + +JENKINS="http://localhost:8080/jenkins" +USER="lrm" +TOKEN="" +RESOURCE="r1" +CLIENT_ID="my-script-v1" # arbitrary identifier shown on server dashboard +POLL_INTERVAL=3 # seconds between status checks +HEARTBEAT_INTERVAL=10 # seconds between heartbeats while lock is held + +# ── 1. Acquire ─────────────────────────────────────────────────────────────── +ACQUIRE=$(curl -s -u "${USER}:${TOKEN}" \ + -X POST \ + -H "Content-Type: application/json" \ + -d "{ + \"lockRequest\": { \"resource\": \"${RESOURCE}\" }, + \"clientId\": \"${CLIENT_ID}\", + \"heartbeatIntervalSeconds\": ${HEARTBEAT_INTERVAL} + }" \ + "${JENKINS}/lockable-resources/remote/v1/acquire/") + +echo "Acquire response: ${ACQUIRE}" +LOCK_ID=$(echo "${ACQUIRE}" | grep -o '"lockId":"[^"]*"' | cut -d'"' -f4) + +if [ -z "${LOCK_ID}" ]; then + echo "ERROR: no lockId in response" >&2 + exit 1 +fi +echo "Enqueued with lockId=${LOCK_ID}" + +# ── 2. Poll until ACQUIRED ─────────────────────────────────────────────────── +while true; do + STATUS=$(curl -s -u "${USER}:${TOKEN}" \ + "${JENKINS}/lockable-resources/remote/v1/acquire/${LOCK_ID}/") + + STATE=$(echo "${STATUS}" | grep -o '"state":"[^"]*"' | cut -d'"' -f4) + echo " state=${STATE}" + + case "${STATE}" in + ACQUIRED) echo "Lock acquired!"; break ;; + QUEUED) sleep "${POLL_INTERVAL}" ;; + SKIPPED) echo "Resource busy, skipIfLocked=true → skipping"; exit 0 ;; + FAILED|EXPIRED|CANCELLED|UNKNOWN) + echo "ERROR: acquire failed with state=${STATE}" >&2 + echo "Response: ${STATUS}" >&2 + exit 1 ;; + esac +done + +# ── 3. Send heartbeats in background ──────────────────────────────────────── +heartbeat() { + while true; do + sleep "${HEARTBEAT_INTERVAL}" + curl -s -o /dev/null -u "${USER}:${TOKEN}" \ + -X POST \ + "${JENKINS}/lockable-resources/remote/v1/lease/${LOCK_ID}/heartbeat" \ + || echo "WARN: heartbeat failed (will retry)" >&2 + done +} +heartbeat & +HEARTBEAT_PID=$! + +# ── 4. Do your work here ───────────────────────────────────────────────────── +echo ">>> Resource ${RESOURCE} is locked — running critical section" +sleep 30 # replace with actual work + +# ── 5. Release ─────────────────────────────────────────────────────────────── +kill "${HEARTBEAT_PID}" 2>/dev/null || true + +curl -s -o /dev/null -u "${USER}:${TOKEN}" \ + -X POST \ + "${JENKINS}/lockable-resources/remote/v1/lease/${LOCK_ID}/release" + +echo "Lock released." +``` + +--- + +### Lock by label (any matching resource) + +```bash +curl -s -u "${USER}:${TOKEN}" \ + -X POST \ + -H "Content-Type: application/json" \ + -d '{ + "lockRequest": { "label": "gpu", "quantity": 1 }, + "clientId": "my-script" + }' \ + "${JENKINS}/lockable-resources/remote/v1/acquire/" +``` + +`quantity: 0` (or omit) means _all_ matching resources; `quantity: N` picks N. + +--- + +### Skip if already locked (non-blocking) + +```bash +curl -s -u "${USER}:${TOKEN}" \ + -X POST \ + -H "Content-Type: application/json" \ + -d '{ + "lockRequest": { "resource": "r1", "skipIfLocked": true } + }' \ + "${JENKINS}/lockable-resources/remote/v1/acquire/" +``` + +Response state will be `SKIPPED` instead of `QUEUED` if the resource is busy. + +--- + +### Lock with a timeout (fail instead of waiting forever) + +```bash +curl -s -u "${USER}:${TOKEN}" \ + -X POST \ + -H "Content-Type: application/json" \ + -d '{ + "lockRequest": { + "resource": "r1", + "timeoutForAllocateResource": 5, + "timeoutUnit": "MINUTES" + }, + "clientId": "my-script" + }' \ + "${JENKINS}/lockable-resources/remote/v1/acquire/" +``` + +If not acquired within 5 minutes the state becomes `FAILED` with `errorCode: LOCK_WAIT_TIMEOUT`. + +--- + +### Queue ordering: `priority` and `inversePrecedence` + +Current remote API behavior: + +- `priority` is applied. Larger number wins queue position. +- `inversePrecedence` is accepted in the payload, but is not currently applied to remote queue ordering. + +For now, use `priority` if you need ordering control for remote waiters. + +Priority example: + +```bash +curl -s -u "${USER}:${TOKEN}" \ + -X POST \ + -H "Content-Type: application/json" \ + -d '{ + "lockRequest": { + "label": "gpu", + "quantity": 1, + "priority": 50 + }, + "clientId": "batch-high-priority" + }' \ + "${JENKINS}/lockable-resources/remote/v1/acquire/" +``` + +Inverse precedence payload example (currently informational for remote queueing): + +```bash +curl -s -u "${USER}:${TOKEN}" \ + -X POST \ + -H "Content-Type: application/json" \ + -d '{ + "lockRequest": { + "label": "gpu", + "quantity": 1, + "inversePrecedence": true + }, + "clientId": "batch-lifo" + }' \ + "${JENKINS}/lockable-resources/remote/v1/acquire/" +``` + +--- + +### API reference + +#### `POST /lockable-resources/remote/v1/acquire/` + +Request body (JSON): + +| Field | Type | Required | Description | +|---|---|---|---| +| `lockRequest.resource` | string | one of resource/label/extra | exact resource name | +| `lockRequest.label` | string | one of resource/label/extra | Jenkins label expression | +| `lockRequest.quantity` | int | no | number of label matches to acquire (0 = all) | +| `lockRequest.skipIfLocked` | bool | no | return SKIPPED instead of queuing | +| `lockRequest.timeoutForAllocateResource` | long | no | max wait time (default: unlimited) | +| `lockRequest.timeoutUnit` | string | no | `MINUTES` (default), `SECONDS`, `HOURS` | +| `lockRequest.priority` | int | no | higher number = higher queue priority | +| `lockRequest.inversePrecedence` | bool | no | accepted field; currently not applied to remote queue ordering | +| `lockRequest.variable` | string | no | env var name for acquired resource name | +| `lockRequest.reason` | string | no | human-readable lock reason | +| `lockRequest.extra` | array | no | additional resources to lock atomically | +| `clientId` | string | no | identifier shown in server dashboard | +| `heartbeatIntervalSeconds` | int | no | must be > 0 when provided | + +Response `202 Accepted`: +```json +{ "lockId": "lr-abc123", "state": "QUEUED" } +``` + +Error responses: + +| HTTP | errorCode | Meaning | +|---|---|---| +| 400 | `MISSING_TARGET` | No resource/label/extra in request | +| 403 | `REMOTE_API_DISABLED` | Remote API not enabled on server | +| 403 | *(no body)* | Authentication failure or missing RemoteUse permission | +| 404 | `UNKNOWN_RESOURCE` | Resource does not exist or is not exposed by exposeLabel | +| 404 | `UNKNOWN_LABEL` | Label does not match any exposed resource | + +--- + +#### `GET /lockable-resources/remote/v1/acquire/{lockId}/` + +Response `200 OK`: +```json +{ + "lockId": "lr-abc123", + "state": "ACQUIRED", + "lockEnvVars": { "RESOURCE": "r1", "RESOURCE0": "r1" } +} +``` + +States: + +| State | Meaning | +|---|---| +| `QUEUED` | Waiting for resource to become free | +| `ACQUIRED` | Lock held — safe to do work and send heartbeats | +| `SKIPPED` | `skipIfLocked` was true and resource was busy | +| `FAILED` | Acquire rejected or timed out (see `errorCode`) | +| `EXPIRED` | Allocation timeout elapsed | + +--- + +#### `POST /lockable-resources/remote/v1/lease/{lockId}/heartbeat` + +Must be sent every `heartbeatIntervalSeconds` while holding the lock. +If missed too many times the server marks the lock as `STALE` and an admin can force-release it. + +Response: `204 No Content` on success, `410 Gone` if lock is not active. + +--- + +#### `POST /lockable-resources/remote/v1/lease/{lockId}/release` + +Releases the lock. Idempotent — safe to call even if already released. + +Response: `204 No Content`. + +--- + +### Credentials note + +Use **username + API token** (not the account password). +Sending the plain account password triggers Jenkins CSRF crumb checks, which +returns HTTP 403 even with correct credentials. + +Create an API token at: +`${JENKINS}/user/${USER}/security/` → **Add new Token** diff --git a/src/doc/examples/remote-lock-pipeline-pattern.md b/src/doc/examples/remote-lock-pipeline-pattern.md new file mode 100644 index 000000000..549d33be8 --- /dev/null +++ b/src/doc/examples/remote-lock-pipeline-pattern.md @@ -0,0 +1,130 @@ +# Remote lock from Jenkins Pipeline (local gate + remote lock) + +This pattern is useful when your Jenkins controller acts as a client of a remote lock server. + +It uses two locks: + +1. A local lock (gate) to serialize requests on the client controller. +2. A remote lock (`serverId`) as the authoritative lock on the remote server. + +Always acquire in this order: **local first, remote second**. + +Always release in reverse order: remote lock exits first, then local gate exits. + +--- + +## Why use a local gate + +- Avoids stampedes from many local builds to the same remote selector. +- Gives local queue visibility on the client controller. +- Lets you apply client-side pause/fail-fast policy when remote server is unreachable. + +--- + +## Static remote resource + +Create the same local gate resource once on the client controller (for example, `remote-server-1::resourceA`). + +```groovy +String serverId = 'remote-server-1' +String remoteResource = 'resourceA' +String localGate = "${serverId}::${remoteResource}" + +lock(resource: localGate) { + lock(resource: remoteResource, serverId: serverId) { + // critical section + } +} +``` + +--- + +## Dynamic remote resource name + +Use a namespaced local gate derived from `serverId` and the chosen remote name. + +```groovy +String serverId = 'remote-server-1' +String remoteResource = "resourceB-${env.BUILD_NUMBER}" +String localGate = "${serverId}::${remoteResource}" + +// remoteResource must exist (and be exposed) on the remote server +lock(resource: localGate) { + lock(resource: remoteResource, serverId: serverId) { + // critical section + } +} +``` + +--- + +## Dynamic capacity from remote label (recommended for pools) + +For pooled resources, prefer label + quantity remotely instead of inventing many names. + +```groovy +String serverId = 'remote-server-1' +String label = 'labelA' +int quantity = 10 +String localGate = "${serverId}::label:${label}::qty:${quantity}" + +lock(resource: localGate) { + lock(label: label, quantity: quantity, serverId: serverId) { + // critical section using any 10 matching resources from remote server + } +} +``` + +--- + +## Environment variables for remote allocations + +Set `variable` in the remote `lock(...)` step to receive allocated resource names and properties +in the pipeline environment. + +For `variable: 'PLC'` and two allocated resources, the block receives: + +- `PLC` = comma-separated list of allocated resource names +- `PLC0`, `PLC1`, ... = indexed allocated resource names +- `PLC0_`, `PLC1_`, ... = indexed resource properties (for example IP values) +- `PLC_SERVER_ID` = remote serverId that granted the lock +- `PLC_LOCK_ID` = remote lockId + +Example: + +```groovy +lock(label: 'plc', quantity: 2, serverId: 'remote-server-1', variable: 'PLC') { + echo "Allocated PLCs: ${env.PLC}" + echo "Remote server: ${env.PLC_SERVER_ID}" + echo "Lock id: ${env.PLC_LOCK_ID}" + + // Example property access when each PLC has property 'ip' + echo "PLC #0 ip: ${env.PLC0_ip}" + echo "PLC #1 ip: ${env.PLC1_ip}" +} +``` + +--- + +## Safety rules + +- Keep one global ordering rule in all jobs: local gate -> remote lock. +- Namespace local gate names with `serverId` to avoid collisions. +- Do not treat local gate acquisition as remote ownership. +- Add timeout/retry policy around the outer flow if needed. + +## Single source of truth + +The remote server is the single source of truth for concrete resource ownership and selection. + +Local gate state only serializes demand on the client side. It does **not** mean a concrete remote +resource is already assigned. + +This is especially important for label-based requests (`lock(label: ..., quantity: ...)`): + +- Local queue order controls when a request is sent to the remote server. +- Remote server state decides *which* concrete resources are granted. +- The final allocation may differ from assumptions made on the client side while waiting. + +In short: use local gate order for fairness on the client, but trust remote allocation results as +authoritative. diff --git a/src/doc/examples/resource-properties.md b/src/doc/examples/resource-properties.md new file mode 100644 index 000000000..2f5ec0818 --- /dev/null +++ b/src/doc/examples/resource-properties.md @@ -0,0 +1,110 @@ +# Resource Properties + +Resources can have custom **properties** (name:value pairs) that are exposed +as environment variables when the resource is locked. + +## Defining properties + +Properties can be added to a resource via: + +- **Web UI** — Manage Jenkins → Lockable Resources → edit a resource → add properties +- **JCasC** (Jenkins Configuration as Code): + +```yaml +unclassified: + lockableResourcesManager: + resources: + - name: "staging-server" + properties: + - name: "HOST" + value: "192.168.1.10" + - name: "PORT" + value: "8080" +``` + +## Accessing properties in a pipeline + +Properties are exposed as environment variables **only when the `variable` +parameter is specified** in the `lock()` step. + +### Naming pattern + +| Variable | Value | +|----------|-------| +| `{variable}` | Comma-separated list of all locked resource names | +| `{variable}0` | Name of the first locked resource | +| `{variable}0_{PROPERTY_NAME}` | Value of that resource's property | +| `{variable}1` | Name of the second locked resource (if any) | +| `{variable}1_{PROPERTY_NAME}` | Value of the second resource's property | + +### Example: Read properties after locking by name + +```groovy +pipeline { + agent any + stages { + stage('Deploy') { + options { + lock(resource: 'staging-server', variable: 'LOCKED') + } + steps { + echo "Resource: ${env.LOCKED0}" // staging-server + echo "Host: ${env.LOCKED0_HOST}" // 192.168.1.10 + echo "Port: ${env.LOCKED0_PORT}" // 8080 + } + } + } +} +``` + +### Example: Lock by label and read properties + +```groovy +pipeline { + agent any + stages { + stage('Test') { + options { + lock(label: 'gpu', quantity: 1, variable: 'GPU') + } + steps { + echo "Got: ${env.GPU0}" + echo "GPU model: ${env.GPU0_MODEL}" + } + } + } +} +``` + +## Filtering resources by properties + +Use a `resourceMatchScript` to lock only resources whose properties match +specific criteria: + +```groovy +lock(extra: [ + [$class: 'LockableResourcesStruct', + resourceMatchScript: [ + $class: 'SecureGroovyScript', + script: ''' + resourceInstance.properties.any { + it.name == "ENV" && it.value == "staging" + } + ''', + sandbox: true + ], + resourceNumber: '1' + ] +]) { + echo "Got a staging resource: ${env.LOCKED_RESOURCE0}" +} +``` + +## Common pitfalls + +1. **Missing `variable` parameter** — without it, no environment variables are + created. This is the most common reason properties appear to be `null`. +2. **Property name is case-sensitive** — if the property is named `host`, the + env var is `LOCKED0_host`, not `LOCKED0_HOST`. +3. **Properties are only available inside the lock block** — they cannot be + accessed after the lock is released. diff --git a/src/doc/examples/scripted-vs-declarative-pipeline.md b/src/doc/examples/scripted-vs-declarative-pipeline.md new file mode 100644 index 000000000..a28e8be01 --- /dev/null +++ b/src/doc/examples/scripted-vs-declarative-pipeline.md @@ -0,0 +1,39 @@ +# Scripted vs declarative pipeline + +Due an historical reason is not possible to use exact same syntax in the declarative and scripted pipeline. +In declarative pipeline you must so far set **resource : null**. + +## Declarative + +``` groovy +pipeline { + agent any + + stages { + stage("Build") { + steps { + lock(label: 'printer', quantity: 1, resource : null) { + echo 'printer locked' + } + } + } + } +} +``` + +## Scripted + +``` groovy +node() { + stage("Build") { + lock(label: 'printer', quantity: 1) { + echo 'printer locked' + } + } +} +``` + +## Pitfalls + +Setting `quantity` to `null`, `0` or a smaller number, all available resources of that label are locked at once. +See [#198 - Lock All resources by setting quantity to 0 is not documented](https://github.com/jenkinsci/lockable-resources-plugin/issues/198). diff --git a/src/doc/examples/timeout-inside-lock.md b/src/doc/examples/timeout-inside-lock.md new file mode 100644 index 000000000..fa68003ac --- /dev/null +++ b/src/doc/examples/timeout-inside-lock.md @@ -0,0 +1,76 @@ +# Timeout Inside Lock + +When a pipeline uses both `timeout` and `lock`, the placement of `timeout` +determines whether queue wait time counts against the deadline. + +## Problem + +If `timeout` wraps the entire pipeline or stage, the clock starts before the +lock is acquired. A job that waits a long time in the queue may time out +before it gets a chance to run: + +```groovy +pipeline { + agent any + options { + // Clock starts immediately — includes queue wait time! + timeout(time: 5, unit: 'HOURS') + } + stages { + stage('Deploy') { + steps { + lock('my-resource') { + echo 'Deploying...' + } + } + } + } +} +``` + +## Solution + +Place `timeout` **inside** `lock` so the countdown begins only after the +resource has been acquired: + +```groovy +pipeline { + agent any + stages { + stage('Deploy') { + steps { + lock('my-resource') { + timeout(time: 5, unit: 'HOURS') { + echo 'Deploying...' + } + } + } + } + } +} +``` + +This way a job can wait in the queue as long as necessary without the +timeout expiring prematurely. + +## Stage-level variant + +The same pattern works with `options` at the stage level: + +```groovy +pipeline { + agent any + stages { + stage('Deploy') { + options { + lock('my-resource') + } + steps { + timeout(time: 5, unit: 'HOURS') { + echo 'Deploying...' + } + } + } + } +} +``` diff --git a/src/main/java/org/jenkins/plugins/lockableresources/BackwardCompatibility.java b/src/main/java/org/jenkins/plugins/lockableresources/BackwardCompatibility.java index 12b7af7c4..065440e06 100644 --- a/src/main/java/org/jenkins/plugins/lockableresources/BackwardCompatibility.java +++ b/src/main/java/org/jenkins/plugins/lockableresources/BackwardCompatibility.java @@ -1,48 +1,72 @@ -/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * - * Copyright (c) 2016, Florian Hug. All rights reserved. * - * * - * This file is part of the Jenkins Lockable Resources Plugin and is * - * published under the MIT license. * - * * - * See the "LICENSE.txt" file for more information. * - * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ - +/* + * The MIT License + * + * See the "LICENSE.txt" file for full copyright and license information. + */ package org.jenkins.plugins.lockableresources; import hudson.init.InitMilestone; import hudson.init.Initializer; - import java.util.ArrayList; -import java.util.Arrays; +import java.util.Collections; import java.util.List; - -import org.jenkinsci.plugins.workflow.steps.StepContext; -import org.jenkins.plugins.lockableresources.queue.LockableResourcesStruct; -import org.jenkins.plugins.lockableresources.queue.QueuedContextStruct; -import org.jenkins.plugins.lockableresources.LockableResource; -import org.jenkins.plugins.lockableresources.LockableResourcesManager; - import java.util.logging.Level; import java.util.logging.Logger; +import jenkins.model.GlobalConfiguration; +import jenkins.model.Jenkins; +import org.jenkins.plugins.lockableresources.queue.LockableResourcesStruct; +import org.jenkinsci.plugins.workflow.steps.StepContext; +/** + * This class migrates "active" queuedContexts from LockableResource to LockableResourcesManager + * + * @deprecated Migration code for field introduced in 1.8 (since 1.11) + */ +@Deprecated +@ExcludeFromJacocoGeneratedReport public final class BackwardCompatibility { - private static final Logger LOG = Logger.getLogger(BackwardCompatibility.class.getName()); + private static final Logger LOG = Logger.getLogger(BackwardCompatibility.class.getName()); + + private BackwardCompatibility() {} - @Initializer(after = InitMilestone.JOB_LOADED) - public static void compatibilityMigration() { - LOG.log(Level.FINE, "lockable-resource-plugin compatibility migration task run"); - List resources = LockableResourcesManager.get().getResources(); - for (LockableResource resource : resources) { - List queuedContexts = resource.getQueuedContexts(); - if (queuedContexts.size() > 0) { - for (StepContext queuedContext : queuedContexts) { - List resourcesNames = new ArrayList(); - resourcesNames.add(resource.getName()); - LockableResourcesStruct resourceHolder = new LockableResourcesStruct(resourcesNames, "", 0); - LockableResourcesManager.get().queueContext(queuedContext, Arrays.asList(resourceHolder), resource.getName()); - } - queuedContexts.clear(); - } - } - } -} \ No newline at end of file + @Initializer(after = InitMilestone.JOB_LOADED) + public static void compatibilityMigration() { + Jenkins jenkins = Jenkins.getInstanceOrNull(); + if (jenkins == null) { + return; + } + LockableResourcesManager lrm = GlobalConfiguration.all().get(LockableResourcesManager.class); + if (lrm == null) { + lrm = jenkins.getDescriptorByType(LockableResourcesManager.class); + } + if (lrm == null) { + LOG.fine("Skipping queuedContexts migration because LockableResourcesManager is not registered yet"); + return; + } + synchronized (LockableResourcesManager.syncResources) { + List resources = lrm.getResources(); + LOG.log( + Level.FINE, + "lockable-resources-plugin compatibility migration task run for " + resources.size() + + " resources"); + for (LockableResource resource : resources) { + List queuedContexts = resource.getQueuedContexts(); + if (!queuedContexts.isEmpty()) { + for (StepContext queuedContext : queuedContexts) { + List resourcesNames = new ArrayList<>(); + resourcesNames.add(resource.getName()); + LockableResourcesStruct resourceHolder = new LockableResourcesStruct(resourcesNames, "", 0); + lrm.queueContext( + queuedContext, + Collections.singletonList(resourceHolder), + resource.getName(), + null, + false, + 0); + } + queuedContexts.clear(); + } + } + } + } +} diff --git a/src/main/java/org/jenkins/plugins/lockableresources/ExcludeFromJacocoGeneratedReport.java b/src/main/java/org/jenkins/plugins/lockableresources/ExcludeFromJacocoGeneratedReport.java new file mode 100644 index 000000000..d776cc4ee --- /dev/null +++ b/src/main/java/org/jenkins/plugins/lockableresources/ExcludeFromJacocoGeneratedReport.java @@ -0,0 +1,10 @@ +package org.jenkins.plugins.lockableresources; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.METHOD, ElementType.TYPE, ElementType.ANNOTATION_TYPE, ElementType.CONSTRUCTOR}) +public @interface ExcludeFromJacocoGeneratedReport {} diff --git a/src/main/java/org/jenkins/plugins/lockableresources/FreeDeadJobs.java b/src/main/java/org/jenkins/plugins/lockableresources/FreeDeadJobs.java new file mode 100644 index 000000000..bea9b160f --- /dev/null +++ b/src/main/java/org/jenkins/plugins/lockableresources/FreeDeadJobs.java @@ -0,0 +1,66 @@ +/* + * The MIT License + * + * See the "LICENSE.txt" file for full copyright and license information. + */ +package org.jenkins.plugins.lockableresources; + +import hudson.init.InitMilestone; +import hudson.init.Initializer; +import java.util.ArrayList; +import java.util.List; +import java.util.logging.Level; +import java.util.logging.Logger; +import jenkins.model.GlobalConfiguration; +import jenkins.model.Jenkins; + +/** + * Sometimes after re-starts (jenkins crashed or what ever) are resources still locked by build, but + * the build is no more running. This script will 'unlock' all resource assigned to dead builds + */ +@ExcludeFromJacocoGeneratedReport +public final class FreeDeadJobs { + private static final Logger LOG = Logger.getLogger(FreeDeadJobs.class.getName()); + + private FreeDeadJobs() {} + + @Initializer(after = InitMilestone.JOB_LOADED) + public static void freePostMortemResources() { + Jenkins jenkins = Jenkins.getInstanceOrNull(); + if (jenkins == null) { + return; + } + LockableResourcesManager lrm = GlobalConfiguration.all().get(LockableResourcesManager.class); + if (lrm == null) { + lrm = jenkins.getDescriptorByType(LockableResourcesManager.class); + } + if (lrm == null) { + LOG.fine("Skipping post mortem resource cleanup because LockableResourcesManager is not registered yet"); + return; + } + boolean freedAny = false; + synchronized (LockableResourcesManager.syncResources) { + List orphan = new ArrayList<>(); + LOG.log(Level.FINE, "lockable-resources-plugin free post mortem task run"); + for (LockableResource resource : lrm.getResources()) { + if (resource.getBuild() != null && !resource.getBuild().isInProgress()) { + orphan.add(resource); + } + } + + for (LockableResource resource : orphan) { + LOG.log( + Level.INFO, + "lockable-resources-plugin reset resource " + + resource.getName() + + " due post mortem job: " + + resource.getBuildName()); + resource.recycle(); + freedAny = true; + } + } + if (freedAny) { + LockableResourcesManager.scheduleQueueMaintenance(); + } + } +} diff --git a/src/main/java/org/jenkins/plugins/lockableresources/LockStep.java b/src/main/java/org/jenkins/plugins/lockableresources/LockStep.java index 739a0b043..17d54d371 100644 --- a/src/main/java/org/jenkins/plugins/lockableresources/LockStep.java +++ b/src/main/java/org/jenkins/plugins/lockableresources/LockStep.java @@ -1,145 +1,339 @@ package org.jenkins.plugins.lockableresources; +import edu.umd.cs.findbugs.annotations.CheckForNull; +import edu.umd.cs.findbugs.annotations.NonNull; +import edu.umd.cs.findbugs.annotations.Nullable; +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import hudson.Extension; +import hudson.model.AutoCompletionCandidates; +import hudson.model.Item; +import hudson.model.TaskListener; +import hudson.util.FormValidation; +import hudson.util.ListBoxModel; import java.io.Serializable; import java.util.ArrayList; import java.util.Arrays; +import java.util.Collections; import java.util.List; - -import org.jenkinsci.plugins.workflow.steps.AbstractStepDescriptorImpl; -import org.jenkinsci.plugins.workflow.steps.AbstractStepImpl; +import java.util.Locale; +import java.util.Set; +import java.util.logging.Logger; +import java.util.stream.Collectors; +import jenkins.model.Jenkins; +import org.jenkinsci.plugins.workflow.steps.Step; +import org.jenkinsci.plugins.workflow.steps.StepContext; +import org.jenkinsci.plugins.workflow.steps.StepDescriptor; +import org.jenkinsci.plugins.workflow.steps.StepExecution; +import org.kohsuke.stapler.AncestorInPath; import org.kohsuke.stapler.DataBoundConstructor; import org.kohsuke.stapler.DataBoundSetter; import org.kohsuke.stapler.QueryParameter; +import org.kohsuke.stapler.interceptor.RequirePOST; -import hudson.Extension; -import hudson.model.AutoCompletionCandidates; -import hudson.util.FormValidation; -import hudson.Util; +public class LockStep extends Step implements Serializable { + private static final Logger LOG = Logger.getLogger(LockStep.class.getName()); -import edu.umd.cs.findbugs.annotations.Nullable; -import edu.umd.cs.findbugs.annotations.CheckForNull; + private static final long serialVersionUID = -953609907239674360L; + + @CheckForNull + public String resource = null; + + @CheckForNull + @SuppressFBWarnings(value = "PA_PUBLIC_PRIMITIVE_ATTRIBUTE", justification = "Preserve API compatibility.") + public String label = null; + + /** The reason why this resource is being locked, displayed in the UI while locked. */ + @CheckForNull + @SuppressFBWarnings(value = "PA_PUBLIC_PRIMITIVE_ATTRIBUTE", justification = "Preserve API compatibility.") + public String reason = null; + + @SuppressFBWarnings(value = "PA_PUBLIC_PRIMITIVE_ATTRIBUTE", justification = "Preserve API compatibility.") + public int quantity = 0; + + /** name of environment variable to store locked resources in */ + @CheckForNull + @SuppressFBWarnings(value = "PA_PUBLIC_PRIMITIVE_ATTRIBUTE", justification = "Preserve API compatibility.") + public String variable = null; + + @SuppressFBWarnings(value = "PA_PUBLIC_PRIMITIVE_ATTRIBUTE", justification = "Preserve API compatibility.") + public boolean inversePrecedence = false; + + @SuppressFBWarnings(value = "PA_PUBLIC_PRIMITIVE_ATTRIBUTE", justification = "Preserve API compatibility.") + public String resourceSelectStrategy = ResourceSelectStrategy.SEQUENTIAL.name(); + + @SuppressFBWarnings(value = "PA_PUBLIC_PRIMITIVE_ATTRIBUTE", justification = "Preserve API compatibility.") + public boolean skipIfLocked = false; + + @CheckForNull + @SuppressFBWarnings(value = "PA_PUBLIC_PRIMITIVE_ATTRIBUTE", justification = "Preserve API compatibility.") + public String serverId = null; + + @CheckForNull + @SuppressFBWarnings(value = "PA_PUBLIC_PRIMITIVE_ATTRIBUTE", justification = "Preserve API compatibility.") + public List extra = null; + + @SuppressFBWarnings(value = "PA_PUBLIC_PRIMITIVE_ATTRIBUTE", justification = "Preserve API compatibility.") + public int priority = 0; + + /** + * Timeout in the specified {@link #timeoutUnit} for waiting to acquire the resource. + * 0 means no timeout (wait indefinitely). When the timeout expires, the step fails + * with an exception instead of waiting forever. + */ + @SuppressFBWarnings(value = "PA_PUBLIC_PRIMITIVE_ATTRIBUTE", justification = "Preserve API compatibility.") + public long timeoutForAllocateResource = 0; + + /** + * Time unit for {@link #timeoutForAllocateResource}. Defaults to MINUTES. + */ + @SuppressFBWarnings(value = "PA_PUBLIC_PRIMITIVE_ATTRIBUTE", justification = "Preserve API compatibility.") + public String timeoutUnit = "MINUTES"; + + // it should be LockStep() - without params. But keeping this for backward compatibility + // so `lock('resource1')` still works and `lock(label: 'label1', quantity: 3)` works too (resource + // is not required) + @DataBoundConstructor + public LockStep(@Nullable String resource) { + if (resource != null && !resource.trim().isEmpty()) { + if (!resource.equals(resource.trim())) { + LOG.warning("The provided 'resource' should not start or end with spaces."); + } + this.resource = resource.trim(); + } + } + + @DataBoundSetter + public void setInversePrecedence(boolean inversePrecedence) { + this.inversePrecedence = inversePrecedence; + } + + @DataBoundSetter + public void setResourceSelectStrategy(String resourceSelectStrategy) { + if (resourceSelectStrategy != null && !resourceSelectStrategy.isEmpty()) { + // Validate the strategy is valid + try { + ResourceSelectStrategy.valueOf(resourceSelectStrategy.toUpperCase(Locale.ENGLISH)); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException(Messages.error_invalidResourceSelectionStrategy( + resourceSelectStrategy, + Arrays.stream(ResourceSelectStrategy.values()) + .map(Enum::toString) + .map(s -> s.toLowerCase(Locale.ENGLISH)) + .collect(Collectors.joining(", ")))); + } + this.resourceSelectStrategy = resourceSelectStrategy; + } + } + + @DataBoundSetter + public void setSkipIfLocked(boolean skipIfLocked) { + this.skipIfLocked = skipIfLocked; + } + + @DataBoundSetter + public void setServerId(String serverId) { + if (serverId != null && !serverId.trim().isEmpty()) { + if (!serverId.equals(serverId.trim())) { + LOG.warning("The provided 'serverId' should not start or end with spaces."); + } + this.serverId = serverId.trim(); + } + } + + @DataBoundSetter + public void setLabel(String label) { + if (label != null && !label.trim().isEmpty()) { + if (!label.equals(label.trim())) { + LOG.warning("The provided 'label' should not start or end with spaces."); + } + this.label = label.trim(); + } + } + + @DataBoundSetter + public void setReason(String reason) { + if (reason != null && !reason.trim().isEmpty()) { + this.reason = reason.trim(); + } + } + + @DataBoundSetter + public void setVariable(String variable) { + if (variable != null && !variable.trim().isEmpty()) { + if (!variable.equals(variable.trim())) { + LOG.warning("The provided 'variable' should not start or end with spaces."); + } + this.variable = variable.trim(); + } + } + + @DataBoundSetter + public void setQuantity(int quantity) { + this.quantity = quantity; + } + + @DataBoundSetter + public void setPriority(int priority) { + this.priority = priority; + } + + @DataBoundSetter + public void setExtra(@CheckForNull List extra) { + this.extra = extra; + } + + @DataBoundSetter + public void setTimeoutForAllocateResource(long timeoutForAllocateResource) { + this.timeoutForAllocateResource = Math.max(0, timeoutForAllocateResource); + } + + @DataBoundSetter + public void setTimeoutUnit(String timeoutUnit) { + if (timeoutUnit != null && !timeoutUnit.trim().isEmpty()) { + // Validate it is a valid TimeUnit name + try { + java.util.concurrent.TimeUnit.valueOf(timeoutUnit.toUpperCase(Locale.ENGLISH)); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("Invalid timeoutUnit: " + timeoutUnit); + } + this.timeoutUnit = timeoutUnit.toUpperCase(Locale.ENGLISH); + } + } + + @Extension + public static final class DescriptorImpl extends StepDescriptor { + + @Override + public String getFunctionName() { + return "lock"; + } + + @NonNull + @Override + public String getDisplayName() { + return Messages.LockStep_displayName(); + } + + @Override + public boolean takesImplicitBlockArgument() { + return true; + } + + @RequirePOST + public AutoCompletionCandidates doAutoCompleteResource( + @QueryParameter String value, @AncestorInPath Item item) { + return RequiredResourcesProperty.DescriptorImpl.doAutoCompleteResourceNames(value, item); + } + + @RequirePOST + public ListBoxModel doFillResourceSelectStrategyItems(@AncestorInPath Item item) { + if (item != null) { + item.checkPermission(Item.CONFIGURE); + } else { + Jenkins.get().checkPermission(Jenkins.ADMINISTER); + } + ListBoxModel items = new ListBoxModel(); + for (ResourceSelectStrategy resSelStrategy : ResourceSelectStrategy.values()) { + items.add(resSelStrategy.name()); + } + return items; + } + + @RequirePOST + public static FormValidation doCheckLabel( + @QueryParameter String value, @QueryParameter String resource, @AncestorInPath Item item) { + return LockStepResource.DescriptorImpl.doCheckLabel(value, resource, item); + } + + @RequirePOST + public static FormValidation doCheckResource( + @QueryParameter String value, @QueryParameter String label, @AncestorInPath Item item) { + return LockStepResource.DescriptorImpl.doCheckLabel(label, value, item); + } + + @RequirePOST + public static FormValidation doCheckResourceSelectStrategy( + @QueryParameter String resourceSelectStrategy, @AncestorInPath Item item) { + // check permission, security first + if (item != null) { + item.checkPermission(Item.CONFIGURE); + } else { + Jenkins.get().checkPermission(Jenkins.ADMINISTER); + } + if (resourceSelectStrategy != null && !resourceSelectStrategy.isEmpty()) { + try { + ResourceSelectStrategy.valueOf(resourceSelectStrategy.toUpperCase(Locale.ENGLISH)); + } catch (IllegalArgumentException e) { + return FormValidation.error(Messages.error_invalidResourceSelectionStrategy( + resourceSelectStrategy, + Arrays.stream(ResourceSelectStrategy.values()) + .map(Enum::toString) + .map(strategy -> strategy.toLowerCase(Locale.ENGLISH)) + .collect(Collectors.joining(", ")))); + } + } + return FormValidation.ok(); + } + + @RequirePOST + public ListBoxModel doFillTimeoutUnitItems(@AncestorInPath Item item) { + if (item != null) { + item.checkPermission(Item.CONFIGURE); + } else { + Jenkins.get().checkPermission(Jenkins.ADMINISTER); + } + ListBoxModel items = new ListBoxModel(); + items.add("Seconds", "SECONDS"); + items.add("Minutes", "MINUTES"); + items.add("Hours", "HOURS"); + return items; + } + + @Override + public Set> getRequiredContext() { + return Collections.singleton(TaskListener.class); + } + } + + @Override + public String toString() { + if (extra != null && !extra.isEmpty()) { + return getResources().stream() + .map(res -> "{" + res.toString() + "}") + .collect(Collectors.joining(",")); + } else if (resource != null || label != null) { + String ret = LockStepResource.toString(resource, label, quantity, reason); + if (this.priority != 0) { + ret += ", Priority: " + this.priority; + } + return ret; + } else { + return "nothing"; + } + } + + // ------------------------------------------------------------------------- + /** Label and resource are mutual exclusive. */ + public void validate(boolean allowEmptyOrNullValues) { + LockStepResource.validate( + resource, label, resourceSelectStrategy, extra, priority, inversePrecedence, allowEmptyOrNullValues); + } + + // ------------------------------------------------------------------------- + public List getResources() { + List resources = new ArrayList<>(); + if (resource != null || label != null) { + resources.add(new LockStepResource(resource, label, quantity, reason)); + } -public class LockStep extends AbstractStepImpl implements Serializable { - - @CheckForNull - public String resource = null; - - @CheckForNull - public String label = null; - - public int quantity = 0; - - /** name of environment variable to store locked resources in */ - @CheckForNull - public String variable = null; - - public boolean inversePrecedence = false; - - @CheckForNull - public List extra = null; - - // it should be LockStep() - without params. But keeping this for backward compatibility - // so `lock('resource1')` still works and `lock(label: 'label1', quantity: 3)` works too (resource is not required) - @DataBoundConstructor - public LockStep(String resource) { - if (resource != null && !resource.isEmpty()) { - this.resource = resource; - } - } - - @DataBoundSetter - public void setInversePrecedence(boolean inversePrecedence) { - this.inversePrecedence = inversePrecedence; - } - - @DataBoundSetter - public void setLabel(String label) { - if (label != null && !label.isEmpty()) { - this.label = label; - } - } - - @DataBoundSetter - public void setVariable(String variable) { - if (variable != null && !variable.isEmpty()) { - this.variable = variable; - } - } - - @DataBoundSetter - public void setQuantity(int quantity) { - this.quantity = quantity; - } - - @DataBoundSetter - public void setExtra(List extra) { - this.extra = extra; - } - - @Extension - public static final class DescriptorImpl extends AbstractStepDescriptorImpl { - - public DescriptorImpl() { - super(LockStepExecution.class); - } - - @Override - public String getFunctionName() { - return "lock"; - } - - @Override - public String getDisplayName() { - return "Lock shared resource"; - } - - @Override - public boolean takesImplicitBlockArgument() { - return true; - } - - public AutoCompletionCandidates doAutoCompleteResource(@QueryParameter String value) { - return RequiredResourcesProperty.DescriptorImpl.doAutoCompleteResourceNames(value); - } - - public static FormValidation doCheckLabel(@QueryParameter String value, @QueryParameter String resource) { - return LockStepResource.DescriptorImpl.doCheckLabel(value, resource); - } - - public static FormValidation doCheckResource(@QueryParameter String value, @QueryParameter String label) { - return LockStepResource.DescriptorImpl.doCheckLabel(label, value); - } - } - - public String toString() { - if (extra != null && !extra.isEmpty()) { - StringBuilder builder = new StringBuilder(); - for (LockStepResource resource : getResources()) { - builder.append("{" + resource.toString() + "},"); - } - return builder.toString(); - } else { - return LockStepResource.toString(resource, label, quantity); - } - } - - /** - * Label and resource are mutual exclusive. - */ - public void validate() throws Exception { - LockStepResource.validate(resource, label, quantity); - } - - public List getResources() { - List resources = new ArrayList<>(); - resources.add(new LockStepResource(resource, label, quantity)); - - if (extra != null) { - resources.addAll(extra); - } - return resources; - } - - private static final long serialVersionUID = 1L; + if (extra != null) { + resources.addAll(extra); + } + return resources; + } + @Override + public StepExecution start(StepContext context) { + return new LockStepExecution(this, context); + } } diff --git a/src/main/java/org/jenkins/plugins/lockableresources/LockStepExecution.java b/src/main/java/org/jenkins/plugins/lockableresources/LockStepExecution.java index 87aa18558..5a3893cdb 100644 --- a/src/main/java/org/jenkins/plugins/lockableresources/LockStepExecution.java +++ b/src/main/java/org/jenkins/plugins/lockableresources/LockStepExecution.java @@ -1,145 +1,459 @@ package org.jenkins.plugins.lockableresources; +import edu.umd.cs.findbugs.annotations.NonNull; +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import hudson.EnvVars; +import hudson.model.Run; +import hudson.model.TaskListener; import java.io.IOException; +import java.io.PrintStream; import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; import java.util.List; -import java.util.Set; +import java.util.Locale; +import java.util.Map; +import java.util.Map.Entry; import java.util.logging.Level; import java.util.logging.Logger; - +import java.util.stream.Collectors; +import org.jenkins.plugins.lockableresources.actions.LockedResourcesBuildAction; import org.jenkins.plugins.lockableresources.queue.LockableResourcesStruct; +import org.jenkins.plugins.lockableresources.remote.RemoteLockRouting; +import org.jenkins.plugins.lockableresources.remote.RemoteLockSession; import org.jenkinsci.plugins.workflow.graph.FlowNode; import org.jenkinsci.plugins.workflow.steps.AbstractStepExecutionImpl; import org.jenkinsci.plugins.workflow.steps.BodyExecutionCallback; import org.jenkinsci.plugins.workflow.steps.BodyInvoker; import org.jenkinsci.plugins.workflow.steps.EnvironmentExpander; import org.jenkinsci.plugins.workflow.steps.StepContext; -import org.jenkinsci.plugins.workflow.steps.StepContextParameter; +import org.jenkinsci.plugins.workflow.support.actions.PauseAction; -import com.google.common.base.Joiner; -import com.google.inject.Inject; +public class LockStepExecution extends AbstractStepExecutionImpl implements RemoteLockSession.Host { -import hudson.EnvVars; -import hudson.model.Run; -import hudson.model.TaskListener; -import org.jenkinsci.plugins.workflow.support.actions.PauseAction; + private static final long serialVersionUID = 1391734561272059623L; + + private static final Logger LOGGER = Logger.getLogger(LockStepExecution.class.getName()); + + private final LockStep step; + + /** Non-null once a remote {@code lock()} flow has started; owns the acquire/poll/heartbeat/release machine. */ + private RemoteLockSession remoteSession; + + public LockStepExecution(LockStep step, StepContext context) { + super(context); + this.step = step; + } + + @Override + public boolean start() throws Exception { + // normally it might raise an exception, but we check it in the function .validate() + // therefore we can skip the try-catch here. + ResourceSelectStrategy resourceSelectStrategy = + ResourceSelectStrategy.valueOf(step.resourceSelectStrategy.toUpperCase(Locale.ENGLISH)); + + PrintStream logger = getContext().get(TaskListener.class).getLogger(); + + Run run = getContext().get(Run.class); + + List resourceHolderList = new ArrayList<>(); + + List available; + LinkedHashMap> lockedResources = new LinkedHashMap<>(); + LockableResourcesManager lrm = LockableResourcesManager.get(); + + if (RemoteLockRouting.isRemoteRequest(step, lrm)) { + remoteSession = new RemoteLockSession(); + return remoteSession.start(this); + } + step.validate(lrm.isAllowEmptyOrNullValues()); + + synchronized (LockableResourcesManager.syncResources) { + LockableResourcesManager.printLogs("Trying to acquire lock on [" + step + "]", Level.FINE, LOGGER, logger); + + getContext().get(FlowNode.class).addAction(new PauseAction("Lock")); + + if (!lrm.isAllowEmptyOrNullValues() || acquireLock()) { + List resourceNames = new ArrayList<>(); + for (LockStepResource resource : step.getResources()) { + List resources = new ArrayList<>(); + if (resource.resource != null) { + if (lrm.createResource(resource.resource)) { + LockableResourcesManager.printLogs( + "Resource [" + resource.resource + "] did not exist. Created.", + Level.FINE, + LOGGER, + logger); + } + resources.add(resource.resource); + resourceNames.addAll(resources); + } else { + resourceNames.add("N/A"); + } + resourceHolderList.add(new LockableResourcesStruct(resources, resource.label, resource.quantity)); + } + LockedResourcesBuildAction.addLog(run, resourceNames, "try", step.toString()); + // determine if there are enough resources available to proceed + available = lrm.getAvailableResources(resourceHolderList, logger, resourceSelectStrategy); + if (available == null || available.isEmpty()) { + LOGGER.fine("No available resources: " + available); + onLockFailed(logger, resourceHolderList); + return false; + } + + if (!lrm.lock(available, run, step.reason)) { + // this here is very defensive code, and you will probably never hit it. (hopefully) + LOGGER.warning("Internal program error: Can not lock resources: " + available); + onLockFailed(logger, resourceHolderList); + return true; + } + + // since LockableResource contains transient variables, they cannot be correctly serialized + // hence we use their unique resource names and properties + for (LockableResource resource : available) { + lockedResources.put(resource.getName(), resource.getProperties()); + } + } + + LockStepExecution.proceed(lockedResources, getContext(), step.toString(), step.variable); + } + + return false; + } + + // --------------------------------------------------------------------------- + // RemoteLockSession.Host - the remote acquire/poll/heartbeat/release machine lives in RemoteLockSession; + // these are the thin step-side hooks it calls back into (expose the step context, run the lock body). + + @Override + public StepContext context() { + return getContext(); + } + + @Override + public LockStep step() { + return step; + } + + @Override + @SuppressFBWarnings( + value = "REC_CATCH_EXCEPTION", + justification = "Fail-closed: any failure while starting the body must fail the session.") + public void runBody( + String displayTarget, + @edu.umd.cs.findbugs.annotations.CheckForNull Map lockEnvVars, + String lockId) { + try { + Run build = getContext().get(Run.class); + FlowNode node = getContext().get(FlowNode.class); + PrintStream logger = getContext().get(TaskListener.class).getLogger(); + LockableResourcesManager.printLogs( + "Remote lock acquired on [" + + step + + "] (serverId=" + + remoteSession.getServerId() + + ", lockId=" + + lockId + + ")", + Level.FINE, + LOGGER, + logger); + LockedResourcesBuildAction.addLog( + build, Collections.singletonList(displayTarget), "acquired", step.toString()); + PauseAction.endCurrentPause(node); + + BodyInvoker bodyInvoker = getContext().newBodyInvoker().withCallback(new RemoteCallback(displayTarget)); + if (lockEnvVars != null && !lockEnvVars.isEmpty()) { + Map envWithRemoteMetadata = + withRemoteMetadata(lockEnvVars, step.variable, remoteSession, lockId); + bodyInvoker.withContext(EnvironmentExpander.merge( + getContext().get(EnvironmentExpander.class), new EnvVarsExpander(envWithRemoteMetadata))); + } + bodyInvoker.start(); + } catch (Exception ex) { + remoteSession.fail(this, ex); + } + } + + // --------------------------------------------------------------------------- + /** + * Checks if a lock can be acquired based on the step's properties: label, resource, and extra. + * To acquire a lock, at least one of these properties must be non-null and non-empty. + */ + private boolean acquireLock() { + if (step.label != null) { + return true; + } + if (step.resource != null) { + return true; + } + if (step.extra != null && !step.extra.isEmpty()) { + return true; + } + LOGGER.warning("No lock will be acquired. Either the label, resource or extra is null or empty."); + LOGGER.warning("Step: " + step); + LOGGER.warning("Label: " + step.label); + LOGGER.warning("Resource: " + step.resource); + LOGGER.warning("Extra: " + step.extra); + return false; + } + + // --------------------------------------------------------------------------- + /** + * Executed when the lock() function fails. No available resources, or we failed to lock available + * resources if the resource is known, we could output the active/blocking job/build + */ + private void onLockFailed(PrintStream logger, List resourceHolderList) { + + if (step.skipIfLocked) { + this.printBlockCause(logger, resourceHolderList); + LockableResourcesManager.printLogs( + "[" + step + "] is not free, skipping execution ...", Level.FINE, LOGGER, logger); + getContext().onSuccess(null); + } else { + this.printBlockCause(logger, resourceHolderList); + String waitMsg = "[" + step + "] is not free, waiting for execution ..."; + if (step.timeoutForAllocateResource > 0) { + waitMsg += " (timeout: " + step.timeoutForAllocateResource + " " + + step.timeoutUnit.toLowerCase(java.util.Locale.ENGLISH) + ")"; + } + LockableResourcesManager.printLogs(waitMsg, Level.FINE, LOGGER, logger); + LockableResourcesManager lrm = LockableResourcesManager.get(); + lrm.queueContext( + getContext(), + resourceHolderList, + step.toString(), + step.variable, + step.inversePrecedence, + step.priority, + step.reason, + step.timeoutForAllocateResource, + step.timeoutUnit); + } + } + + private void printBlockCause(PrintStream logger, List resourceHolderList) { + LockableResourcesManager lrm = LockableResourcesManager.get(); + LockableResource resource = this.step.resource != null ? lrm.fromName(this.step.resource) : null; + + if (resource != null) { + final String logMessage = resource.getLockCauseDetail(); + if (logMessage != null && !logMessage.isEmpty()) + LockableResourcesManager.printLogs(logMessage, Level.FINE, LOGGER, logger); + } else { + // looks like ordered by label + lrm.getAvailableResources(resourceHolderList, logger, null); + } + } + + // --------------------------------------------------------------------------- + @SuppressFBWarnings(value = "REC_CATCH_EXCEPTION", justification = "not sure which exceptions might be catch.") + public static void proceed( + final LinkedHashMap> lockedResources, + StepContext context, + String resourceDescription, + final String variable) { + Run build; + FlowNode node; + PrintStream logger; + try { + build = context.get(Run.class); + node = context.get(FlowNode.class); + logger = context.get(TaskListener.class).getLogger(); + LockableResourcesManager.printLogs( + "Lock acquired on [" + resourceDescription + "]", Level.FINE, LOGGER, logger); + } catch (Exception e) { + context.onFailure(e); + return; + } + + try { + List resourceNames = new ArrayList<>(lockedResources.keySet()); + LockedResourcesBuildAction.addLog(build, resourceNames, "acquired", resourceDescription); + PauseAction.endCurrentPause(node); + BodyInvoker bodyInvoker = + context.newBodyInvoker().withCallback(new Callback(resourceNames, resourceDescription)); + if (variable != null && !variable.isEmpty()) { + // set the variable for the duration of the block + bodyInvoker.withContext(EnvironmentExpander.merge( + context.get(EnvironmentExpander.class), new EnvironmentExpander() { + private static final long serialVersionUID = -3431466225193397896L; + + @Override + public void expand(@NonNull EnvVars env) { + Map variables = buildLockEnvVars(variable, lockedResources); + if (variables == null) { + return; + } + LOGGER.finest("Setting " + + variables.entrySet().stream() + .map(e -> e.getKey() + "=" + e.getValue()) + .collect(Collectors.joining(", ")) + + " for the duration of the block"); + env.overrideAll(variables); + } + })); + } + bodyInvoker.start(); + } catch (IOException | InterruptedException e) { + LOGGER.warning("proceed done with failure " + resourceDescription); + throw new RuntimeException(e); + } + } + + /** + * Builds the {@code lockEnvVars} map injected for the duration of a {@code lock()} block, following + * local semantics exactly: {@code variable="X"} with locked resources {@code r1, r2} yields + * {@code X="r1,r2"}, {@code X0="r1"}, {@code X0_=}, {@code X1="r2"}, ... Property env vars + * are included. Returns {@code null} when {@code variable} is null/empty. + * + *

Shared by the local flow ({@link #proceed}) and the remote bridge so the two never drift. All + * inputs (resource names and property name/value strings) are serializable, so the remote server can + * generate the same map and bridge it to the client. + */ + @edu.umd.cs.findbugs.annotations.CheckForNull + public static Map buildLockEnvVars( + @edu.umd.cs.findbugs.annotations.CheckForNull String variable, + @NonNull LinkedHashMap> lockedResources) { + if (variable == null || variable.isEmpty()) { + return null; + } + LinkedHashMap variables = new LinkedHashMap<>(); + variables.put(variable, String.join(",", lockedResources.keySet())); + int index = 0; + for (Entry> lockResourceEntry : lockedResources.entrySet()) { + String lockEnvName = variable + index; + variables.put(lockEnvName, lockResourceEntry.getKey()); + for (LockableResourceProperty lockProperty : lockResourceEntry.getValue()) { + variables.put(lockEnvName + "_" + lockProperty.getName(), lockProperty.getValue()); + } + ++index; + } + return variables; + } + + /** + * Adds remote metadata env vars for remote lock blocks while preserving local env semantics. + * For variable "X" this appends: X_SERVER_ID and X_LOCK_ID. + */ + @NonNull + private static Map withRemoteMetadata( + @NonNull Map lockEnvVars, + @edu.umd.cs.findbugs.annotations.CheckForNull String variable, + @edu.umd.cs.findbugs.annotations.CheckForNull RemoteLockSession remoteSession, + @NonNull String lockId) { + LinkedHashMap merged = new LinkedHashMap<>(lockEnvVars); + if (variable == null || variable.isEmpty() || remoteSession == null) { + return merged; + } + + String serverId = remoteSession.getServerId(); + if (serverId != null && !serverId.isEmpty()) { + merged.put(variable + "_SERVER_ID", serverId); + } + merged.put(variable + "_LOCK_ID", lockId); + return merged; + } + + /** Serializable {@link EnvironmentExpander} that injects a precomputed env-var map (named/static to avoid capturing the step). */ + private static final class EnvVarsExpander extends EnvironmentExpander { + + private static final long serialVersionUID = -6921365277116143418L; + private final Map envVars; + + EnvVarsExpander(Map envVars) { + this.envVars = envVars; + } + + @Override + public void expand(@NonNull EnvVars env) { + env.overrideAll(envVars); + } + } + + private static final class Callback extends BodyExecutionCallback.TailCall { + + private static final long serialVersionUID = -2024890670461847666L; + private final List resourceNames; + private final String resourceDescription; + + Callback(List resourceNames, String resourceDescription) { + this.resourceNames = resourceNames; + this.resourceDescription = resourceDescription; + } + + @Override + protected void finished(StepContext context) throws Exception { + Run build = context.get(Run.class); + LockedResourcesBuildAction.addLog(build, this.resourceNames, "released", this.resourceDescription); + LockableResourcesManager.get().unlockNames(this.resourceNames, build); + LockableResourcesManager.printLogs( + "Lock released on resource [" + this.resourceDescription + "]", + Level.FINE, + LOGGER, + context.get(TaskListener.class).getLogger()); + } + } + + @SuppressFBWarnings( + value = {"REC_CATCH_EXCEPTION", "SE_INNER_CLASS"}, + justification = "Best-effort release logging; the callback is serialized as part of the step execution.") + private final class RemoteCallback extends BodyExecutionCallback.TailCall { + + private static final long serialVersionUID = -6771337682719425678L; + + private final String remoteResource; + + private RemoteCallback(String remoteResource) { + this.remoteResource = remoteResource; + } + + @Override + protected void finished(StepContext context) { + remoteSession.onBodyFinished(LockStepExecution.this); + + Run build; + try { + build = context.get(Run.class); + LockedResourcesBuildAction.addLog( + build, Collections.singletonList(remoteResource), "released", step.toString()); + LockableResourcesManager.printLogs( + "Remote lock released on [" + + step + + "] (serverId=" + + remoteSession.getServerId() + + ", lockId=" + + remoteSession.getLockId() + + ")", + Level.FINE, + LOGGER, + context.get(TaskListener.class).getLogger()); + } catch (Exception ex) { + LOGGER.log(Level.WARNING, "Failed to write remote release log", ex); + } + } + } + + @Override + public void stop(@NonNull Throwable cause) { + if (remoteSession != null) { + remoteSession.stop(this, cause); + return; + } -public class LockStepExecution extends AbstractStepExecutionImpl { - - private static final Joiner COMMA_JOINER = Joiner.on(','); - - @Inject(optional = true) - private LockStep step; - - @StepContextParameter - private transient Run run; - - @StepContextParameter - private transient TaskListener listener; - - @StepContextParameter - private transient FlowNode node; - - private static final Logger LOGGER = Logger.getLogger(LockStepExecution.class.getName()); - - @Override - public boolean start() throws Exception { - step.validate(); - - node.addAction(new PauseAction("Lock")); - listener.getLogger().println("Trying to acquire lock on [" + step + "]"); - - List resourceHolderList = new ArrayList<>(); - - for (LockStepResource resource : step.getResources()) { - List resources = new ArrayList(); - if (resource.resource != null) { - if (LockableResourcesManager.get().createResource(resource.resource)) { - listener.getLogger().println("Resource [" + resource + "] did not exist. Created."); - } - resources.add(resource.resource); - } - resourceHolderList.add(new LockableResourcesStruct(resources, resource.label, resource.quantity)); - } - - // determine if there are enough resources available to proceed - Set available = LockableResourcesManager.get().checkResourcesAvailability(resourceHolderList, listener.getLogger(), null); - if (available == null || !LockableResourcesManager.get().lock(available, run, getContext(), step.toString(), step.variable, step.inversePrecedence)) { - listener.getLogger().println("[" + step + "] is locked, waiting..."); - LockableResourcesManager.get().queueContext(getContext(), resourceHolderList, step.toString()); - } // proceed is called inside lock if execution is possible - return false; - } - - public static void proceed(final List resourcenames, StepContext context, String resourceDescription, final String variable, boolean inversePrecedence) { - Run r = null; - FlowNode node = null; - try { - r = context.get(Run.class); - node = context.get(FlowNode.class); - context.get(TaskListener.class).getLogger().println("Lock acquired on [" + resourceDescription + "]"); - } catch (Exception e) { - context.onFailure(e); - return; - } - - LOGGER.finest("Lock acquired on [" + resourceDescription + "] by " + r.getExternalizableId()); - try { - PauseAction.endCurrentPause(node); - BodyInvoker bodyInvoker = context.newBodyInvoker(). - withCallback(new Callback(resourcenames, resourceDescription, variable, inversePrecedence)). - withDisplayName(null); - if(variable != null && variable.length()>0) - // set the variable for the duration of the block - bodyInvoker.withContext(EnvironmentExpander.merge(context.get(EnvironmentExpander.class), new EnvironmentExpander() { - @Override - public void expand(EnvVars env) throws IOException, InterruptedException { - final String resources = COMMA_JOINER.join(resourcenames); - LOGGER.finest("Setting [" + variable + "] to [" + resources - + "] for the duration of the block"); - - env.override(variable, resources); - } - })); - bodyInvoker.start(); - } catch (IOException | InterruptedException e) { - throw new RuntimeException(e); - } - } - - private static final class Callback extends BodyExecutionCallback.TailCall { - - private final List resourceNames; - private final String resourceDescription; - private final String variable; - private final boolean inversePrecedence; - - Callback(List resourceNames, String resourceDescription, String variable, boolean inversePrecedence) { - this.resourceNames = resourceNames; - this.resourceDescription = resourceDescription; - this.variable = variable; - this.inversePrecedence = inversePrecedence; - } - - protected void finished(StepContext context) throws Exception { - LockableResourcesManager.get().unlockNames(this.resourceNames, context.get(Run.class), this.variable, this.inversePrecedence); - context.get(TaskListener.class).getLogger().println("Lock released on resource [" + resourceDescription + "]"); - LOGGER.finest("Lock released on [" + resourceDescription + "]"); - } - - private static final long serialVersionUID = 1L; - - } - - @Override - public void stop(Throwable cause) throws Exception { - boolean cleaned = LockableResourcesManager.get().unqueueContext(getContext()); - if (!cleaned) { - LOGGER.log(Level.WARNING, "Cannot remove context from lockable resource witing list. The context is not in the waiting list."); - } - getContext().onFailure(cause); - } - - private static final long serialVersionUID = 1L; + boolean cleaned = LockableResourcesManager.get().unqueueContext(getContext()); + if (!cleaned) { + LOGGER.log( + Level.WARNING, + "Cannot remove context from lockable resource waiting list. The context is not in the waiting list."); + } + getContext().onFailure(cause); + } + @Override + public void onResume() { + if (remoteSession != null) { + remoteSession.onResume(this); + } + } } diff --git a/src/main/java/org/jenkins/plugins/lockableresources/LockStepResource.java b/src/main/java/org/jenkins/plugins/lockableresources/LockStepResource.java index 6665a13fe..bc59d7c2a 100644 --- a/src/main/java/org/jenkins/plugins/lockableresources/LockStepResource.java +++ b/src/main/java/org/jenkins/plugins/lockableresources/LockStepResource.java @@ -1,122 +1,222 @@ package org.jenkins.plugins.lockableresources; -import java.io.Serializable; - -import org.jenkinsci.plugins.workflow.steps.AbstractStepDescriptorImpl; -import org.jenkinsci.plugins.workflow.steps.AbstractStepImpl; -import org.kohsuke.stapler.DataBoundConstructor; -import org.kohsuke.stapler.DataBoundSetter; -import org.kohsuke.stapler.QueryParameter; - +import edu.umd.cs.findbugs.annotations.CheckForNull; +import edu.umd.cs.findbugs.annotations.NonNull; +import edu.umd.cs.findbugs.annotations.Nullable; +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import hudson.Extension; +import hudson.Util; import hudson.model.AbstractDescribableImpl; import hudson.model.AutoCompletionCandidates; import hudson.model.Descriptor; +import hudson.model.Item; import hudson.util.FormValidation; -import hudson.Util; - -import edu.umd.cs.findbugs.annotations.Nullable; -import edu.umd.cs.findbugs.annotations.CheckForNull; +import java.io.Serializable; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; +import java.util.stream.Collectors; +import jenkins.model.Jenkins; +import org.kohsuke.stapler.AncestorInPath; +import org.kohsuke.stapler.DataBoundConstructor; +import org.kohsuke.stapler.DataBoundSetter; +import org.kohsuke.stapler.QueryParameter; +import org.kohsuke.stapler.interceptor.RequirePOST; public class LockStepResource extends AbstractDescribableImpl implements Serializable { - @CheckForNull - public String resource = null; - - @CheckForNull - public String label = null; - - public int quantity = 0; - - LockStepResource(String resource, String label, int quantity) { - this.resource = resource; - this.label = label; - this.quantity = quantity; - } - - @DataBoundConstructor - public LockStepResource(String resource) { - if (resource != null && !resource.isEmpty()) { - this.resource = resource; - } - } - - @DataBoundSetter - public void setLabel(String label) { - if (label != null && !label.isEmpty()) { - this.label = label; - } - } - - @DataBoundSetter - public void setQuantity(int quantity) { - this.quantity = quantity; - } - - public String toString() { - return toString(resource, label, quantity); - } - - public static String toString(String resource, String label, int quantity) { - // a label takes always priority - if (label != null) { - if (quantity > 0) { - return "Label: " + label + ", Quantity: " + quantity; - } - return "Label: " + label; - } - // make sure there is an actual resource specified - if (resource != null) { - return resource; - } - return "[no resource/label specified - probably a bug]"; - } - - /** - * Label and resource are mutual exclusive. - */ - public void validate() throws Exception { - validate(resource, label, quantity); - } - - /** - * Label and resource are mutual exclusive. - */ - public static void validate(String resource, String label, int quantity) throws Exception { - if (label != null && !label.isEmpty() && resource != null && !resource.isEmpty()) { - throw new IllegalArgumentException("Label and resource name cannot be specified simultaneously."); - } - } - - private static final long serialVersionUID = 1L; - - @Extension - public static class DescriptorImpl extends Descriptor { - - @Override - public String getDisplayName() { - return "Resource"; - } - - public AutoCompletionCandidates doAutoCompleteResource(@QueryParameter String value) { - return RequiredResourcesProperty.DescriptorImpl.doAutoCompleteResourceNames(value); - } - - public static FormValidation doCheckLabel(@QueryParameter String value, @QueryParameter String resource) { - String resourceLabel = Util.fixEmpty(value); - String resourceName = Util.fixEmpty(resource); - if (resourceLabel != null && resourceName != null) { - return FormValidation.error("Label and resource name cannot be specified simultaneously."); - } - if ((resourceLabel == null) && (resourceName == null)) { - return FormValidation.error("Either label or resource name must be specified."); - } - return FormValidation.ok(); - } - - public static FormValidation doCheckResource(@QueryParameter String value, @QueryParameter String label) { - return doCheckLabel(label, value); - } - } - + @CheckForNull + public String resource = null; + + @CheckForNull + @SuppressFBWarnings(value = "PA_PUBLIC_PRIMITIVE_ATTRIBUTE", justification = "Preserve API compatibility.") + public String label = null; + + @SuppressFBWarnings(value = "PA_PUBLIC_PRIMITIVE_ATTRIBUTE", justification = "Preserve API compatibility.") + public int quantity = 0; + + /** The reason why this resource is being locked, displayed in the UI while locked. */ + @CheckForNull + @SuppressFBWarnings(value = "PA_PUBLIC_PRIMITIVE_ATTRIBUTE", justification = "Preserve API compatibility.") + public String reason = null; + + LockStepResource(@Nullable String resource, @Nullable String label, int quantity) { + this(resource, label, quantity, null); + } + + LockStepResource(@Nullable String resource, @Nullable String label, int quantity, @Nullable String reason) { + this.resource = Util.fixEmptyAndTrim(resource); + this.label = Util.fixEmptyAndTrim(label); + this.quantity = quantity; + this.reason = Util.fixEmptyAndTrim(reason); + } + + @DataBoundConstructor + public LockStepResource(@Nullable String resource) { + this.resource = Util.fixEmptyAndTrim(resource); + } + + @DataBoundSetter + public void setLabel(String label) { + this.label = Util.fixEmptyAndTrim(label); + } + + @DataBoundSetter + public void setQuantity(int quantity) { + this.quantity = quantity; + } + + @DataBoundSetter + public void setReason(String reason) { + this.reason = Util.fixEmptyAndTrim(reason); + } + + @Override + public String toString() { + return toString(resource, label, quantity, reason); + } + + public static String toString(String resource, String label, int quantity) { + return toString(resource, label, quantity, null); + } + + public static String toString(String resource, String label, int quantity, String reason) { + // a label takes always priority + StringBuilder sb = new StringBuilder(); + if (label != null) { + sb.append("Label: ").append(label); + if (quantity > 0) { + sb.append(", Quantity: ").append(quantity); + } + } else if (resource != null) { + sb.append("Resource: ").append(resource); + } else { + return "[no resource/label specified - probably a bug]"; + } + if (reason != null && !reason.isEmpty()) { + sb.append(", Reason: ").append(reason); + } + return sb.toString(); + } + + // ------------------------------------------------------------------------- + /** Label and resource are mutual exclusive. */ + public void validate(boolean allowEmptyOrNullValues) { + validate(resource, label, null, false, 0, false, allowEmptyOrNullValues); + } + + // ------------------------------------------------------------------------- + /** Validate input parameters*/ + public static void validate( + String resource, + String label, + String resourceSelectStrategy, + List extra, + int priority, + boolean inversePrecedence, + boolean allowEmptyOrNullValues) { + validate( + resource, + label, + resourceSelectStrategy, + extra != null && !extra.isEmpty(), + priority, + inversePrecedence, + allowEmptyOrNullValues); + if (extra != null) { + for (LockStepResource e : extra) { + e.validate(allowEmptyOrNullValues); + } + } + } + + // ------------------------------------------------------------------------- + /** + * Label and resource are mutual exclusive. The label, if provided, must be configured (at least + * one resource must have this label). + */ + public static void validate( + String resource, + String label, + String resourceSelectStrategy, + boolean hasExtra, + int priority, + boolean inversePrecedence, + boolean allowEmptyOrNullValues) { + + if (!allowEmptyOrNullValues && !hasExtra && label == null && resource == null) { + throw new IllegalArgumentException(Messages.error_labelOrNameMustBeSpecified()); + } + + if (priority != 0 && inversePrecedence) { + throw new IllegalArgumentException(Messages.error_inversePrecedenceAndPriorityAreSet()); + } + + if (label != null && resource != null) { + throw new IllegalArgumentException(Messages.error_labelAndNameSpecified()); + } + if (label != null && !LockableResourcesManager.get().isValidLabel(label)) { + throw new IllegalArgumentException(Messages.error_labelDoesNotExist(label)); + } + if (resourceSelectStrategy != null) { + try { + ResourceSelectStrategy.valueOf(resourceSelectStrategy.toUpperCase(Locale.ENGLISH)); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException(Messages.error_invalidResourceSelectionStrategy( + resourceSelectStrategy, + Arrays.stream(ResourceSelectStrategy.values()) + .map(Enum::toString) + .map(strategy -> strategy.toLowerCase(Locale.ENGLISH)) + .collect(Collectors.joining(", ")))); + } + } + } + + private static final long serialVersionUID = 1L; + + @Extension + public static class DescriptorImpl extends Descriptor { + + @NonNull + @Override + public String getDisplayName() { + return Messages.LockStepResource_displayName(); + } + + @RequirePOST + public AutoCompletionCandidates doAutoCompleteResource( + @QueryParameter String value, @AncestorInPath Item item) { + return RequiredResourcesProperty.DescriptorImpl.doAutoCompleteResourceNames(value, item); + } + + @RequirePOST + public static FormValidation doCheckLabel( + @QueryParameter String value, @QueryParameter String resource, @AncestorInPath Item item) { + // check permission, security first + if (item != null) { + item.checkPermission(Item.CONFIGURE); + } else { + Jenkins.get().checkPermission(Jenkins.ADMINISTER); + } + + String resourceLabel = Util.fixEmpty(value); + String resourceName = Util.fixEmpty(resource); + if (resourceLabel != null && resourceName != null) { + return FormValidation.error(Messages.error_labelAndNameSpecified()); + } + if ((resourceLabel == null) && (resourceName == null)) { + return FormValidation.error(Messages.error_labelOrNameMustBeSpecified()); + } + if (resourceLabel != null && !LockableResourcesManager.get().isValidLabel(resourceLabel)) { + return FormValidation.error(Messages.error_labelDoesNotExist(resourceLabel)); + } + return FormValidation.ok(); + } + + @RequirePOST + public static FormValidation doCheckResource( + @QueryParameter String value, @QueryParameter String label, @AncestorInPath Item item) { + return doCheckLabel(label, value, item); + } + } } diff --git a/src/main/java/org/jenkins/plugins/lockableresources/LockableResource.java b/src/main/java/org/jenkins/plugins/lockableresources/LockableResource.java index 47b1726c8..443a31e0c 100644 --- a/src/main/java/org/jenkins/plugins/lockableresources/LockableResource.java +++ b/src/main/java/org/jenkins/plugins/lockableresources/LockableResource.java @@ -1,364 +1,876 @@ -/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * - * Copyright (c) 2013, 6WIND S.A. All rights reserved. * - * * - * This file is part of the Jenkins Lockable Resources Plugin and is * - * published under the MIT license. * - * * - * See the "LICENSE.txt" file for more information. * - * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ +/* + * The MIT License + * + * See the "LICENSE.txt" file for full copyright and license information. + */ package org.jenkins.plugins.lockableresources; +import static java.text.DateFormat.MEDIUM; +import static java.text.DateFormat.SHORT; + +import edu.umd.cs.findbugs.annotations.CheckForNull; +import edu.umd.cs.findbugs.annotations.NonNull; +import edu.umd.cs.findbugs.annotations.Nullable; import groovy.lang.Binding; -import groovy.lang.GroovyShell; import hudson.Extension; -import hudson.PluginManager; import hudson.Util; +import hudson.console.ModelHyperlinkNote; import hudson.model.AbstractDescribableImpl; -import hudson.model.AbstractBuild; import hudson.model.Descriptor; +import hudson.model.Label; import hudson.model.Queue; -import hudson.model.Run; import hudson.model.Queue.Item; import hudson.model.Queue.Task; +import hudson.model.Run; import hudson.model.User; +import hudson.model.labels.LabelAtom; import hudson.tasks.Mailer.UserProperty; - -import java.io.IOException; import java.io.Serializable; +import java.text.DateFormat; +import java.text.SimpleDateFormat; import java.util.ArrayList; -import java.util.Arrays; -import java.util.Iterator; +import java.util.Date; +import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.TreeMap; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutionException; import java.util.logging.Level; import java.util.logging.Logger; - import jenkins.model.Jenkins; - +import jenkins.util.SystemProperties; +import org.jenkins.plugins.lockableresources.remote.RemoteLockManager; +import org.jenkins.plugins.lockableresources.remote.RemoteLockRecord; +import org.jenkins.plugins.lockableresources.util.Constants; import org.jenkinsci.plugins.scriptsecurity.sandbox.groovy.SecureGroovyScript; import org.jenkinsci.plugins.workflow.steps.StepContext; -import org.jinterop.winreg.IJIWinReg.saveFile; +import org.kohsuke.accmod.Restricted; +import org.kohsuke.accmod.restrictions.NoExternalUse; import org.kohsuke.stapler.DataBoundConstructor; import org.kohsuke.stapler.DataBoundSetter; import org.kohsuke.stapler.export.Exported; import org.kohsuke.stapler.export.ExportedBean; -import com.infradna.tool.bridge_method_injector.WithBridgeMethods; - -import edu.umd.cs.findbugs.annotations.CheckForNull; -import java.util.concurrent.ExecutionException; - -import javax.annotation.Nonnull; -import org.kohsuke.accmod.Restricted; -import org.kohsuke.accmod.restrictions.NoExternalUse; - @ExportedBean(defaultVisibility = 999) public class LockableResource extends AbstractDescribableImpl implements Serializable { - private static final Logger LOGGER = Logger.getLogger(LockableResource.class.getName()); - public static final int NOT_QUEUED = 0; - private static final int QUEUE_TIMEOUT = 60; - public static final String GROOVY_LABEL_MARKER = "groovy:"; - - private final String name; - private String description = ""; - private String labels = ""; - private String reservedBy = null; - - private long queueItemId = NOT_QUEUED; - private String queueItemProject = null; - private transient Run build = null; - // Needed to make the state non-transient - private String buildExternalizableId = null; - private long queuingStarted = 0; - - /** - * Was used within the initial implementation of Pipeline functionality - * using {@link LockStep}, but became deprecated once several resources - * could be locked at once. See queuedContexts in {@link LockableResourcesManager}. - */ - @Deprecated - private List queuedContexts = new ArrayList(); - - @Deprecated - public LockableResource( - String name, String description, String labels, String reservedBy) { - this.name = name; - this.description = description; - this.labels = labels; - this.reservedBy = Util.fixEmptyAndTrim(reservedBy); - } - - @DataBoundConstructor - public LockableResource(String name) { - this.name = name; - } - - private Object readResolve() { - if (queuedContexts == null) { // this field was added after the initial version if this class - queuedContexts = new ArrayList(); - } - return this; - } - - @Deprecated - public List getQueuedContexts() { - return this.queuedContexts; - } - - @DataBoundSetter - public void setDescription(String description) { - this.description = description; - } - - @DataBoundSetter - public void setLabels(String labels) { - this.labels = labels; - } - - @Exported - public String getName() { - return name; - } - - @Exported - public String getDescription() { - return description; - } - - @Exported - public String getLabels() { - return labels; - } - - public boolean isValidLabel(String candidate, Map params) { - return labelsContain(candidate); - } - - private boolean labelsContain(String candidate) { - return makeLabelsList().contains(candidate); - } - - private List makeLabelsList() { - return Arrays.asList(labels.split("\\s+")); - } - - /** - * Checks if the script matches the requirement. - * @param script Script to be executed - * @param params Extra script parameters - * @return {@code true} if the script returns true (resource matches). - * @throws ExecutionException Script execution failed (e.g. due to the missing permissions). Carries info in the cause - */ - @Restricted(NoExternalUse.class) - public boolean scriptMatches(@Nonnull SecureGroovyScript script, @CheckForNull Map params) - throws ExecutionException { - Binding binding = new Binding(params); - binding.setVariable("resourceName", name); - binding.setVariable("resourceDescription", description); - binding.setVariable("resourceLabels", makeLabelsList()); - try { - Object result = script.evaluate(Jenkins.getInstance().getPluginManager().uberClassLoader, binding); - if (LOGGER.isLoggable(Level.FINE)) { - LOGGER.fine("Checked resource " + name + " for " + script.getScript() - + " with " + binding + " -> " + result); - } - return (Boolean) result; - } catch (Exception e) { - throw new ExecutionException("Cannot get boolean result out of groovy expression. See system log for more info", e); - } - } - - @Exported - public String getReservedBy() { - return reservedBy; - } - - @Exported - public boolean isReserved() { - return reservedBy != null; - } - - @Exported - public String getReservedByEmail() { - if (reservedBy != null) { - UserProperty email = null; - User user = Jenkins.getInstance().getUser(reservedBy); - if (user != null) - email = user.getProperty(UserProperty.class); - if (email != null) - return email.getAddress(); - } - return null; - } - - public boolean isQueued() { - this.validateQueuingTimeout(); - return queueItemId != NOT_QUEUED; - } - - // returns True if queued by any other task than the given one - public boolean isQueued(long taskId) { - this.validateQueuingTimeout(); - return queueItemId != NOT_QUEUED && queueItemId != taskId; - } - - public boolean isQueuedByTask(long taskId) { - this.validateQueuingTimeout(); - return queueItemId == taskId; - } - - public void unqueue() { - queueItemId = NOT_QUEUED; - queueItemProject = null; - queuingStarted = 0; - } - - @Exported - public boolean isLocked() { - return getBuild() != null; - } - - /** - * Resolve the lock cause for this resource. It can be reserved or locked. - * - * @return the lock cause or null if not locked - */ - @CheckForNull - public String getLockCause() { - if (isReserved()) { - return String.format("[%s] is reserved by %s", name, reservedBy); - } - if (isLocked()) { - return String.format("[%s] is locked by %s", name, buildExternalizableId); - } - return null; - } - - @WithBridgeMethods(value=AbstractBuild.class, adapterMethod="getAbstractBuild") - public Run getBuild() { - if (build == null && buildExternalizableId != null) { - build = Run.fromExternalizableId(buildExternalizableId); - } - return build; - } - - /** - * @see {@link WithBridgeMethods} - */ - @Deprecated - private Object getAbstractBuild(final Run owner, final Class targetClass) { - return owner instanceof AbstractBuild ? (AbstractBuild) owner : null; - } - - @Exported - public String getBuildName() { - if (getBuild() != null) - return getBuild().getFullDisplayName(); - else - return null; - } - - public void setBuild(Run lockedBy) { - this.build = lockedBy; - if (lockedBy != null) { - this.buildExternalizableId = lockedBy.getExternalizableId(); - } else { - this.buildExternalizableId = null; - } - } - - public Task getTask() { - Item item = Queue.getInstance().getItem(queueItemId); - if (item != null) { - return item.task; - } else { - return null; - } - } - - public long getQueueItemId() { - this.validateQueuingTimeout(); - return queueItemId; - } - - public String getQueueItemProject() { - this.validateQueuingTimeout(); - return this.queueItemProject; - } - - public void setQueued(long queueItemId) { - this.queueItemId = queueItemId; - this.queuingStarted = System.currentTimeMillis() / 1000; - } - - public void setQueued(long queueItemId, String queueProjectName) { - this.setQueued(queueItemId); - this.queueItemProject = queueProjectName; - } - - private void validateQueuingTimeout() { - if (queuingStarted > 0) { - long now = System.currentTimeMillis() / 1000; - if (now - queuingStarted > QUEUE_TIMEOUT) - unqueue(); - } - } - - @DataBoundSetter - public void setReservedBy(String userName) { - this.reservedBy = Util.fixEmptyAndTrim(userName); - } - - public void unReserve() { - this.reservedBy = null; - } - - public void reset() { - this.unReserve(); - this.unqueue(); - this.setBuild(null); - } - - @Override - public String toString() { - return name; - } - - @Override - public int hashCode() { - final int prime = 31; - int result = 1; - result = prime * result + ((name == null) ? 0 : name.hashCode()); - return result; - } - - @Override - public boolean equals(Object obj) { - if (this == obj) - return true; - if (obj == null) - return false; - if (getClass() != obj.getClass()) - return false; - LockableResource other = (LockableResource) obj; - if (name == null) { - if (other.name != null) - return false; - } else if (!name.equals(other.name)) - return false; - return true; - } - - @Extension - public static class DescriptorImpl extends Descriptor { - - @Override - public String getDisplayName() { - return "Resource"; - } - - } - - private static final long serialVersionUID = 1L; + private static final Logger LOGGER = Logger.getLogger(LockableResource.class.getName()); + public static final int NOT_QUEUED = 0; + private static final int QUEUE_TIMEOUT = 60; + public static final String GROOVY_LABEL_MARKER = "groovy:"; + + private final String name; + private String description = ""; + /** + * @deprecated use labelsAsList instead due performance. + */ + @Deprecated + private transient String labels = null; + + private List labelsAsList = new ArrayList<>(); + private String reservedBy = null; + private Date reservedTimestamp = null; + private String note = ""; + + /** + * The reason why this resource is currently locked. Set via the lock() step's reason parameter. + * Cleared when the resource is unlocked. + */ + private String lockReason = ""; + + /** + * Track that a currently reserved resource was originally reserved for someone else, or locked + * for some other job, and explicitly taken away - e.g. for SUT post-mortem while a test job runs. + * Currently this does not track "who" it was taken from nor intend to give it back - just for + * bookkeeping and UI button naming. Cleared when the resource is unReserve'd. + */ + private boolean stolen = false; + + /** + * lockId of the remote holder that has acquired this resource via the remote API, + * or {@code null} if no remote lock is held. Transient: not persisted, lost on restart. + */ + private transient volatile String remoteLockedBy; + + /** + * We can use arbitrary identifier in a temporary lock (e.g. a commit hash of built/tested + * sources), and not overwhelm Jenkins with lots of "garbage" locks. Such locks will be + * automatically removed when freed, if they were not explicitly declared in the Jenkins Configure + * System page. If an originally ephemeral lock is later defined in configuration, it becomes a + * usual persistent lock. If a "usual" lock definition is deleted while it is being held, it + * becomes ephemeral and will disappear when freed. + */ + private boolean ephemeral; + + private List properties = new ArrayList<>(); + + private long queueItemId = NOT_QUEUED; + private String queueItemProject = null; + private transient Run build = null; + // Needed to make the state non-transient + private String buildExternalizableId = null; + private long queuingStarted = 0; + + private static final long serialVersionUID = 1L; + + private static final long SCRIPT_CACHE_TTL_MS = + SystemProperties.getLong(Constants.SYSTEM_PROPERTY_SCRIPT_CACHE_TTL_MS, 30_000L); + private static final long LABEL_CACHE_TTL_MS = + SystemProperties.getLong(Constants.SYSTEM_PROPERTY_LABEL_CACHE_TTL_MS, 30_000L); + + /** Per-resource cache: Groovy script text -> (result, timestamp). */ + private transient volatile ConcurrentHashMap scriptCache; + /** Per-resource cache: label expression -> (result, timestamp). */ + private transient volatile ConcurrentHashMap labelCache; + + private static final class CachedResult { + final boolean value; + final long timestamp; + + CachedResult(boolean value) { + this.value = value; + this.timestamp = System.currentTimeMillis(); + } + + boolean isExpired(long ttlMs) { + return (System.currentTimeMillis() - timestamp) > ttlMs; + } + } + + private ConcurrentHashMap getScriptCache() { + ConcurrentHashMap c = scriptCache; + if (c == null) { + synchronized (this) { + c = scriptCache; + if (c == null) { + scriptCache = c = new ConcurrentHashMap<>(); + } + } + } + return c; + } + + private ConcurrentHashMap getLabelCache() { + ConcurrentHashMap c = labelCache; + if (c == null) { + synchronized (this) { + c = labelCache; + if (c == null) { + labelCache = c = new ConcurrentHashMap<>(); + } + } + } + return c; + } + + void invalidateCaches() { + ConcurrentHashMap sc = scriptCache; + if (sc != null) sc.clear(); + ConcurrentHashMap lc = labelCache; + if (lc != null) lc.clear(); + } + + private transient boolean isNode = false; + + /** + * Was used within the initial implementation of Pipeline functionality using {@link LockStep}, + * but became deprecated once several resources could be locked at once. See queuedContexts in + * {@link LockableResourcesManager}. + * + * @deprecated Replaced with LockableResourcesManager.queuedContexts (since 1.11) + */ + @Deprecated + private List queuedContexts = new ArrayList<>(); + + /** + * @deprecated Use single-argument constructor instead (since 1.8) + */ + @Deprecated + @ExcludeFromJacocoGeneratedReport + public LockableResource(String name, String description, String labels, String reservedBy, String note) { + // todo throw exception, when the name is empty + // todo check if the name contains only valid characters (no spaces, new lines ...) + this.name = name; + this.setDescription(description); + this.setLabelsFromString(labels); + this.setReservedBy(reservedBy); + this.setNote(note); + } + + @DataBoundConstructor + public LockableResource(@CheckForNull String name) { + this.name = Util.fixNull(name); + // todo throw exception, when the name is empty + // todo check if the name contains only valid characters (no spaces, new lines ...) + } + + protected Object readResolve() { + if (queuedContexts == null) { // this field was added after the initial version if this class + queuedContexts = new ArrayList<>(); + } + if (properties == null) { + properties = new ArrayList<>(); + } + this.repairLabels(); + return this; + } + + private void repairLabels() { + if (this.labels == null) { + return; + } + + LOGGER.fine("Repair labels for resource " + this); + this.setLabelsFromString(this.labels); + this.labels = null; + } + + /** + * @deprecated Replaced with LockableResourcesManager.queuedContexts (since 1.11) + */ + @Deprecated + @ExcludeFromJacocoGeneratedReport + public List getQueuedContexts() { + return this.queuedContexts; + } + + public boolean isNodeResource() { + return isNode; + } + + public void setNodeResource(boolean b) { + isNode = b; + } + + @Exported + public String getName() { + return name; + } + + @Exported + public String getDescription() { + return description; + } + + @DataBoundSetter + public void setDescription(@Nullable String description) { + this.description = Util.fixNull(description); + } + + @Exported + public String getNote() { + return this.note; + } + + @DataBoundSetter + public void setNote(@Nullable String note) { + this.note = Util.fixNull(note); + } + + /** + * Returns the reason why this resource is currently locked. + * + * @return The lock reason, or empty string if not set. + */ + @Exported + public String getLockReason() { + return this.lockReason; + } + + /** + * Sets the reason why this resource is being locked. This is typically set via the lock() step's + * reason parameter and cleared when the resource is unlocked. + * + * @param lockReason The reason for locking, or null to clear. + */ + public void setLockReason(@Nullable String lockReason) { + this.lockReason = Util.fixNull(lockReason); + } + + @DataBoundSetter + public void setEphemeral(boolean ephemeral) { + this.ephemeral = ephemeral; + } + + @Exported + public boolean isEphemeral() { + return ephemeral; + } + + /** + * Use getLabelsAsList instead todo This function is marked as deprecated but it is still used in + * tests and jelly (config) files. + */ + @Deprecated + @Exported + public String getLabels() { + return getLabelsAsString(); + } + + /** + * Returns the labels as a single space-separated string. + * + *

This is the non-deprecated replacement for {@link #getLabels()}. + */ + @Restricted(NoExternalUse.class) + public String getLabelsAsString() { + if (this.labelsAsList == null) { + return ""; + } + return String.join(" ", this.labelsAsList); + } + + /** + * @deprecated no equivalent at the time. todo It shall be created new one function + * selLabelsAsList() and use that one. But it must be checked and changed all config.jelly + * files and this might takes more time as expected. That the reason why a deprecated + * function/property is still data-bound-setter + */ + // @Deprecated can not be used, because of JCaC + @DataBoundSetter + public void setLabels(@Nullable String labels) { + setLabelsFromString(labels); + } + + /** + * Sets labels from a space-separated string. + * + *

This is the non-deprecated replacement for {@link #setLabels(String)}. + */ + @Restricted(NoExternalUse.class) + public void setLabelsFromString(@Nullable String labels) { + labels = Util.fixNull(labels); + // todo use label parser from Jenkins.Label to allow the same syntax + this.labelsAsList = new ArrayList<>(); + for (String label : labels.split("\\s+")) { + if (label == null || label.isEmpty()) { + continue; + } + this.labelsAsList.add(label); + } + invalidateCaches(); + } + + /** + * Get labels of this resource + * + * @return List of assigned labels. + */ + @Exported + public List getLabelsAsList() { + return this.labelsAsList; + } + + /** + * Checks if the resource has label *labelToFind* + * + * @param labelToFind Label to find. + * @return {@code true} if this resource contains the label. + */ + @Restricted(NoExternalUse.class) + public boolean hasLabel(@CheckForNull String labelToFind) { + return this.labelsContain(labelToFind); + } + + // ---------------------------------------------------------------------------- + /** + * @deprecated Use isValidLabel(String candidate) + */ + @Deprecated + @ExcludeFromJacocoGeneratedReport + public boolean isValidLabel(String candidate, Map params) { + return isValidLabel(candidate); + } + + // ---------------------------------------------------------------------------- + /** Check if the given *candidate* label is valid or not. + * candidate may be one label or Label expression (see also + * https://www.jenkins.io/doc/pipeline/steps/workflow-durable-task-step/#node-allocate-node). + * Valid means that the resource contains the label or the Label-expression matched. + */ + public boolean isValidLabel(@Nullable String candidate) { + candidate = Util.fixEmptyAndTrim(Util.fixNull(candidate)); + if (candidate == null) { + return false; + } + + if (labelsContain(candidate)) { + return true; + } + + if (LABEL_CACHE_TTL_MS > 0) { + ConcurrentHashMap cache = getLabelCache(); + CachedResult cached = cache.get(candidate); + if (cached != null && !cached.isExpired(LABEL_CACHE_TTL_MS)) { + return cached.value; + } + boolean result = evaluateLabelExpression(candidate); + cache.put(candidate, new CachedResult(result)); + return result; + } + return evaluateLabelExpression(candidate); + } + + private boolean evaluateLabelExpression(@NonNull String candidate) { + final Label labelExpression = Label.parseExpression(candidate); + Set atomLabels = new HashSet<>(); + for (String label : this.getLabelsAsList()) { + atomLabels.add(new LabelAtom(label)); + } + return labelExpression.matches(atomLabels); + } + + // ---------------------------------------------------------------------------- + /** + * Checks if the resource contain label *candidate*. + * + * @param candidate Labels to find. + * @return {@code true} if resource contains label *candidate* + */ + private boolean labelsContain(String candidate) { + return this.getLabelsAsList().contains(candidate); + } + + @Exported + public List getProperties() { + return properties; + } + + @DataBoundSetter + public void setProperties(@Nullable List properties) { + this.properties = (properties == null ? new ArrayList<>() : properties); + } + + /** + * Checks if the script matches the requirement. + * + * @param script Script to be executed + * @param params Extra script parameters + * @return {@code true} if the script returns true (resource matches). + * @throws ExecutionException Script execution failed (e.g. due to the missing permissions). + * Carries info in the cause + */ + @Restricted(NoExternalUse.class) + public boolean scriptMatches(@NonNull SecureGroovyScript script, @CheckForNull Map params) + throws ExecutionException { + if (SCRIPT_CACHE_TTL_MS > 0) { + String cacheKey = buildScriptCacheKey(script.getScript(), params); + ConcurrentHashMap cache = getScriptCache(); + CachedResult cached = cache.get(cacheKey); + if (cached != null && !cached.isExpired(SCRIPT_CACHE_TTL_MS)) { + return cached.value; + } + boolean result = evaluateScript(script, params); + cache.put(cacheKey, new CachedResult(result)); + return result; + } + return evaluateScript(script, params); + } + + /** + * Builds the per-resource script-cache key. + * + *

The cached match result depends not only on the script text but also on the build + * parameters bound into the script's execution (for example a {@code TARGET_ENVIRONMENT} + * parameter referenced by the script). Different jobs commonly share an identical match script + * and differ only by their parameters, so the parameters must form part of the key. Keying on + * the script text alone causes the first build's result to be reused for every other build, + * making them all resolve to the same resource and serialise behind it. + * + * @param scriptText the Groovy match script text + * @param params the parameter bindings supplied to the script (may be {@code null}) + * @return a key combining the script text with a deterministic encoding of the parameters + */ + private static String buildScriptCacheKey(String scriptText, @CheckForNull Map params) { + if (params == null || params.isEmpty()) { + return scriptText; + } + // Sort by key for a deterministic, order-independent encoding. + StringBuilder sb = new StringBuilder(scriptText); + for (Map.Entry entry : new TreeMap<>(params).entrySet()) { + sb.append(' ').append(entry.getKey()).append('=').append(entry.getValue()); + } + return sb.toString(); + } + + private boolean evaluateScript(@NonNull SecureGroovyScript script, @CheckForNull Map params) + throws ExecutionException { + Binding binding = new Binding(params); + binding.setVariable("resourceName", name); + binding.setVariable("resourceDescription", description); + binding.setVariable("resourceLabels", this.getLabelsAsList()); + binding.setVariable("resourceNote", note); + binding.setVariable("resourceLockReason", lockReason); + try { + Object result = script.evaluate(Jenkins.get().getPluginManager().uberClassLoader, binding, null); + if (LOGGER.isLoggable(Level.FINE)) { + LOGGER.fine("Checked resource " + + name + + " for " + + script.getScript() + + " with " + + binding + + " -> " + + result); + } + return (Boolean) result; + } catch (Exception e) { + throw new ExecutionException( + "Cannot get boolean result out of groovy expression. See system log for more info", e); + } + } + + @Exported + public Date getReservedTimestamp() { + return reservedTimestamp == null ? null : new Date(reservedTimestamp.getTime()); + } + + @DataBoundSetter + public void setReservedTimestamp(@Nullable final Date reservedTimestamp) { + this.reservedTimestamp = reservedTimestamp == null ? null : new Date(reservedTimestamp.getTime()); + } + + @Exported + public String getReservedBy() { + return reservedBy; + } + + /** Return true when resource is free. False otherwise */ + @Exported + public boolean isFree() { + return (!this.isLocked() && !this.isReserved() && !this.isQueued()); + } + + @Exported + public boolean isReserved() { + return reservedBy != null; + } + + @Restricted(NoExternalUse.class) + @CheckForNull + public static String getUserName() { + User current = User.current(); + if (current != null) { + return current.getFullName(); + } else { + return null; + } + } + + /** + * Function check if the resources is reserved by currently logged user + * + * @return true when reserved by current user, false otherwise. + */ + @Restricted(NoExternalUse.class) // called by jelly + public boolean isReservedByCurrentUser() { + return (this.reservedBy != null && Objects.equals(getUserName(), this.reservedBy)); + } + + @Exported + public String getReservedByEmail() { + if (isReserved()) { + UserProperty email = null; + User user = Jenkins.get().getUser(reservedBy); + if (user != null) email = user.getProperty(UserProperty.class); + if (email != null) return email.getAddress(); + } + return null; + } + + public boolean isQueued() { + this.validateQueuingTimeout(); + return queueItemId != NOT_QUEUED; + } + + // returns True if queued by any other task than the given one + public boolean isQueued(long taskId) { + this.validateQueuingTimeout(); + return queueItemId != NOT_QUEUED && queueItemId != taskId; + } + + public boolean isQueuedByTask(long taskId) { + this.validateQueuingTimeout(); + return queueItemId == taskId; + } + + public void unqueue() { + queueItemId = NOT_QUEUED; + queueItemProject = null; + queuingStarted = 0; + } + + @Exported + public boolean isLocked() { + return getBuild() != null || remoteLockedBy != null; + } + + public String getRemoteLockedBy() { + return remoteLockedBy; + } + + /** + * Returns the clientId of the remote holder, or {@code null} if the record is + * not found (e.g. after a Jenkins restart) or the caller did not provide one. + */ + @CheckForNull + public String getRemoteLockClientId() { + if (remoteLockedBy == null) { + return null; + } + RemoteLockRecord record = RemoteLockManager.get().find(remoteLockedBy); + return record != null ? record.getClientId() : null; + } + + /** + * Returns the full {@link RemoteLockRecord} for the active remote lock, or {@code null} + * if the resource is not remotely locked or the record is no longer available. + * Used by the Jelly table to display lock details in the heldBy column. + */ + @CheckForNull + public RemoteLockRecord getRemoteLockRecord() { + if (remoteLockedBy == null) { + return null; + } + return RemoteLockManager.get().find(remoteLockedBy); + } + + public void setRemoteLockedBy(String lockId) { + this.remoteLockedBy = lockId; + } + + /** + * Resolve the lock cause for this resource. It can be reserved or locked. + * + * @return the lock cause or null if not locked + */ + @CheckForNull + @Exported + public String getLockCause() { + final DateFormat format = SimpleDateFormat.getDateTimeInstance(MEDIUM, SHORT); + final String timestamp = (reservedTimestamp == null ? "" : format.format(reservedTimestamp)); + if (isReserved()) { + return String.format("[%s] is reserved by %s at %s", name, reservedBy, timestamp); + } + if (isLocked()) { + return String.format("[%s] is locked by %s at %s", name, buildExternalizableId, timestamp); + } + return null; + } + + /** + * Resolve the lock detailed cause for this resource. + * Note: this function is used in lock() step and not in the UI. Therefore + * moving text into localization files does not make really sense. + * + * @return the lock cause or null if not locked + */ + @CheckForNull + @Restricted(NoExternalUse.class) + public String getLockCauseDetail() { + if (this.isReserved()) { + User user = Jenkins.get().getUser(reservedBy); + String userText = user == null ? reservedBy : ModelHyperlinkNote.encodeTo(user); + return String.format("The resource [%s] is reserved by %s.", name, userText); + } + if (this.isLocked()) { + final DateFormat format = SimpleDateFormat.getDateTimeInstance(MEDIUM, SHORT); + Date since = this.getReservedTimestamp(); + final String timestamp = (since == null ? "" : format.format(since)); + Run lockBuild = getBuild(); + if (lockBuild != null) { + return String.format( + "The resource [%s] is locked by build %s since %s.", + name, lockBuild.getFullDisplayName() + " " + ModelHyperlinkNote.encodeTo(lockBuild), timestamp); + } + if (remoteLockedBy != null) { + return String.format( + "The resource [%s] is locked by remote lockId %s since %s.", name, remoteLockedBy, timestamp); + } + } + return null; + } + + public Run getBuild() { + if (build == null && buildExternalizableId != null) { + build = Run.fromExternalizableId(buildExternalizableId); + } + return build; + } + + // --------------------------------------------------------------------------- + @Exported + public String getBuildName() { + if (getBuild() != null) return getBuild().getFullDisplayName(); + else return null; + } + + // --------------------------------------------------------------------------- + public void setBuild(@Nullable Run lockedBy) { + + this.build = lockedBy; + + if (lockedBy != null) { + this.buildExternalizableId = lockedBy.getExternalizableId(); + setReservedTimestamp(new Date()); + } else { + this.buildExternalizableId = null; + setReservedTimestamp(null); + } + } + + public Task getTask() { + Item item = Queue.getInstance().getItem(queueItemId); + if (item != null) { + return item.task; + } else { + return null; + } + } + + public long getQueueItemId() { + this.validateQueuingTimeout(); + return queueItemId; + } + + public String getQueueItemProject() { + this.validateQueuingTimeout(); + return this.queueItemProject; + } + + public void setQueued(long queueItemId) { + this.queueItemId = queueItemId; + this.queuingStarted = System.currentTimeMillis() / 1000; + } + + public void setQueued(long queueItemId, String queueProjectName) { + this.setQueued(queueItemId); + this.queueItemProject = queueProjectName; + } + + private void validateQueuingTimeout() { + if (queuingStarted > 0) { + long now = System.currentTimeMillis() / 1000; + if (now - queuingStarted > QUEUE_TIMEOUT) unqueue(); + } + } + + @DataBoundSetter + public void setReservedBy(String userName) { + this.reservedBy = Util.fixEmptyAndTrim(userName); + } + + public void setStolen() { + this.stolen = true; + } + + @Exported + public boolean isStolen() { + return this.stolen; + } + + public void reserve(String userName) { + reserve(userName, null); + } + + public void reserve(String userName, String reason) { + setReservedBy(userName); + setReservedTimestamp(new Date()); + setLockReason(reason); + } + + public void unReserve() { + this.setReservedBy(null); + this.setReservedTimestamp(null); + this.setLockReason(null); + this.stolen = false; + } + + public void reset() { + this.unReserve(); + this.unqueue(); + this.setBuild(null); + this.setLockReason(null); + invalidateCaches(); + } + + /** + * Copy unconfigurable properties from another instance. Normally, called after "lockable + * resource" configuration change. + * + * @param sourceResource resource with properties to copy from + */ + public void copyUnconfigurableProperties(final LockableResource sourceResource) { + if (sourceResource != null) { + setReservedTimestamp(sourceResource.getReservedTimestamp()); + setNote(sourceResource.getNote()); + setReservedBy(sourceResource.getReservedBy()); + setLockReason(sourceResource.getLockReason()); + } + } + + /** + * Reset unconfigurable properties. Normally, called after "lockable resource" configuration + * change, to make sure that these fields are ignored if defined in CasC configuration file. + */ + public void resetUnconfigurableProperties() { + setReservedBy(null); + setReservedTimestamp(null); + setNote(""); + setLockReason(""); + } + + /** + * Tell LRM to recycle this resource, including notifications for whoever may be waiting in the + * queue so they can proceed immediately. WARNING: Do not use this from inside the lock step + * closure which originally locked this resource, to avoid nasty surprises! Just stick with + * unReserve() and close the closure, if needed. + */ + public void recycle() { + try { + List resources = new ArrayList<>(); + resources.add(this); + org.jenkins.plugins.lockableresources.LockableResourcesManager.get().recycle(resources); + } catch (Exception e) { + this.reset(); + } + } + + @Override + public String toString() { + return name; + } + + @Override + public int hashCode() { + final int prime = 31; + int result = 1; + result = prime * result + ((name == null) ? 0 : name.hashCode()); + return result; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) return true; + if (obj == null) return false; + if (getClass() != obj.getClass()) return false; + LockableResource other = (LockableResource) obj; + if (name == null) { + if (other.name != null) return false; + } else if (!name.equals(other.name)) return false; + return true; + } + + @Extension + public static class DescriptorImpl extends Descriptor { + + @NonNull + @Override + public String getDisplayName() { + return Messages.LockableResource_displayName(); + } + } } diff --git a/src/main/java/org/jenkins/plugins/lockableresources/LockableResourceProperty.java b/src/main/java/org/jenkins/plugins/lockableresources/LockableResourceProperty.java new file mode 100644 index 000000000..2647b623d --- /dev/null +++ b/src/main/java/org/jenkins/plugins/lockableresources/LockableResourceProperty.java @@ -0,0 +1,59 @@ +package org.jenkins.plugins.lockableresources; + +import edu.umd.cs.findbugs.annotations.NonNull; +import hudson.Extension; +import hudson.model.AbstractDescribableImpl; +import hudson.model.Descriptor; +import java.io.Serializable; +import org.kohsuke.stapler.DataBoundConstructor; +import org.kohsuke.stapler.DataBoundSetter; +import org.kohsuke.stapler.export.Exported; +import org.kohsuke.stapler.export.ExportedBean; + +@ExportedBean(defaultVisibility = 999) +public class LockableResourceProperty extends AbstractDescribableImpl + implements Serializable { + + private String name; + private String value; + + @DataBoundConstructor + public LockableResourceProperty() {} + + @DataBoundSetter + public void setName(String name) { + this.name = name; + } + + @DataBoundSetter + public void setValue(String value) { + this.value = value; + } + + @Exported + public String getName() { + return name; + } + + @Exported + public String getValue() { + return value; + } + + @Override + public String toString() { + return name; + } + + @Extension + public static class DescriptorImpl extends Descriptor { + + @NonNull + @Override + public String getDisplayName() { + return "Property"; + } + } + + private static final long serialVersionUID = 1L; +} diff --git a/src/main/java/org/jenkins/plugins/lockableresources/LockableResources.java b/src/main/java/org/jenkins/plugins/lockableresources/LockableResources.java index 321de3453..7aee8812f 100644 --- a/src/main/java/org/jenkins/plugins/lockableresources/LockableResources.java +++ b/src/main/java/org/jenkins/plugins/lockableresources/LockableResources.java @@ -1,33 +1,25 @@ -/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * - * Copyright (c) 2015, 6WIND S.A. All rights reserved. * - * * - * This file is part of the Jenkins Lockable Resources Plugin and is * - * published under the MIT license. * - * * - * See the "LICENSE.txt" file for more information. * - * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ +/* + * The MIT License + * + * See the "LICENSE.txt" file for full copyright and license information. + */ package org.jenkins.plugins.lockableresources; import hudson.Plugin; import hudson.model.Api; - -import java.util.Collections; import java.util.List; - import org.kohsuke.stapler.export.Exported; import org.kohsuke.stapler.export.ExportedBean; @ExportedBean public class LockableResources extends Plugin { - public Api getApi() { - return new Api(this); - } - - @Exported - public List getResources() { - return Collections.unmodifiableList(LockableResourcesManager.get() - .getResources()); - } + public Api getApi() { + return new Api(this); + } + @Exported + public List getResources() { + return LockableResourcesManager.get().getReadOnlyResources(); + } } diff --git a/src/main/java/org/jenkins/plugins/lockableresources/LockableResourcesManager.java b/src/main/java/org/jenkins/plugins/lockableresources/LockableResourcesManager.java index dd28beff6..5f0792546 100644 --- a/src/main/java/org/jenkins/plugins/lockableresources/LockableResourcesManager.java +++ b/src/main/java/org/jenkins/plugins/lockableresources/LockableResourcesManager.java @@ -1,802 +1,2188 @@ -/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * - * Copyright (c) 2013, 6WIND S.A. All rights reserved. * - * * - * This file is part of the Jenkins Lockable Resources Plugin and is * - * published under the MIT license. * - * * - * See the "LICENSE.txt" file for more information. * - * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ +/* + * The MIT License + * + * See the "LICENSE.txt" file for full copyright and license information. + */ package org.jenkins.plugins.lockableresources; +import com.google.common.cache.Cache; +import com.google.common.cache.CacheBuilder; import edu.umd.cs.findbugs.annotations.CheckForNull; -import hudson.Extension; +import edu.umd.cs.findbugs.annotations.NonNull; +import edu.umd.cs.findbugs.annotations.Nullable; +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import hudson.BulkChange; -import hudson.model.AbstractBuild; +import hudson.Extension; +import hudson.Util; +import hudson.console.ModelHyperlinkNote; +import hudson.init.Terminator; +import hudson.model.Descriptor; import hudson.model.Run; - +import hudson.util.FormValidation; import java.io.IOException; import java.io.PrintStream; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Date; +import java.util.HashMap; import java.util.HashSet; import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; import java.util.Set; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.HashMap; -import java.util.List; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Predicate; import java.util.logging.Level; import java.util.logging.Logger; - -import hudson.model.TaskListener; +import java.util.stream.Collectors; import jenkins.model.GlobalConfiguration; import jenkins.model.Jenkins; -import net.sf.json.JSONException; +import jenkins.util.SystemProperties; import net.sf.json.JSONObject; - -import org.apache.commons.lang.StringUtils; -import org.jenkins.plugins.lockableresources.queue.LockableResourcesCandidatesStruct; +import org.jenkins.plugins.lockableresources.actions.LockedResourcesBuildAction; import org.jenkins.plugins.lockableresources.queue.LockableResourcesStruct; -import org.jenkinsci.plugins.scriptsecurity.sandbox.groovy.SecureGroovyScript; import org.jenkins.plugins.lockableresources.queue.QueuedContextStruct; +import org.jenkins.plugins.lockableresources.remote.RemoteQueueEntry; +import org.jenkins.plugins.lockableresources.remote.RemoteResolver; +import org.jenkins.plugins.lockableresources.util.Constants; +import org.jenkinsci.Symbol; +import org.jenkinsci.plugins.scriptsecurity.sandbox.groovy.SecureGroovyScript; import org.jenkinsci.plugins.workflow.steps.StepContext; -import org.kohsuke.stapler.StaplerRequest; - -import edu.umd.cs.findbugs.annotations.Nullable; -import java.util.concurrent.ExecutionException; -import javax.annotation.Nonnull; +import org.kohsuke.accmod.Restricted; +import org.kohsuke.accmod.restrictions.NoExternalUse; +import org.kohsuke.stapler.DataBoundSetter; +import org.kohsuke.stapler.QueryParameter; +import org.kohsuke.stapler.StaplerRequest2; +import org.kohsuke.stapler.verb.POST; @Extension +@Symbol("lockableResourcesManager") public class LockableResourcesManager extends GlobalConfiguration { - @Deprecated - private transient int defaultPriority; - @Deprecated - private transient String priorityParameterName; - private List resources; - - - /** - * Only used when this lockable resource is tried to be locked by {@link LockStep}, - * otherwise (freestyle builds) regular Jenkins queue is used. - */ - private List queuedContexts = new ArrayList(); - - public LockableResourcesManager() { - resources = new ArrayList(); - load(); - } - - public List getResources() { - return resources; - } - - public List getResourcesFromProject(String fullName) { - List matching = new ArrayList(); - for (LockableResource r : resources) { - String rName = r.getQueueItemProject(); - if (rName != null && rName.equals(fullName)) { - matching.add(r); - } - } - return matching; - } - - public List getResourcesFromBuild(Run build) { - List matching = new ArrayList(); - for (LockableResource r : resources) { - Run rBuild = r.getBuild(); - if (rBuild != null && rBuild == build) { - matching.add(r); - } - } - return matching; - } - - public Boolean isValidLabel(String label) - { - return this.getAllLabels().contains(label); - } - - public Set getAllLabels() - { - Set labels = new HashSet(); - for (LockableResource r : this.resources) { - String rl = r.getLabels(); - if (rl == null || "".equals(rl)) - continue; - labels.addAll(Arrays.asList(rl.split("\\s+"))); - } - return labels; - } - - public int getFreeResourceAmount(String label) - { - int free = 0; - for (LockableResource r : this.resources) { - if (r.isLocked() || r.isQueued() || r.isReserved()) - continue; - if (Arrays.asList(r.getLabels().split("\\s+")).contains(label)) - free += 1; - } - return free; - } - - public List getResourcesWithLabel(String label, - Map params) { - List found = new ArrayList(); - for (LockableResource r : this.resources) { - if (r.isValidLabel(label, params)) - found.add(r); - } - return found; - } - - /** - * Get a list of resources matching the script. - * @param script Script - * @param params Additional parameters - * @return List of the matching resources - * @throws ExecutionException Script execution failed for one of the resources. - * It is considered as a fatal failure since the requirement list may be incomplete - * @since TODO - */ - @Nonnull - public List getResourcesMatchingScript(@Nonnull SecureGroovyScript script, - @CheckForNull Map params) throws ExecutionException{ - List found = new ArrayList(); - for (LockableResource r : this.resources) { - if (r.scriptMatches(script, params)) - found.add(r); - } - return found; - } - - public LockableResource fromName(String resourceName) { - if (resourceName != null) { - for (LockableResource r : resources) { - if (resourceName.equals(r.getName())) - return r; - } - } - return null; - } - - public synchronized boolean queue(List resources, - long queueItemId, String queueProjectName) { - for (LockableResource r : resources) - if (r.isReserved() || r.isQueued(queueItemId) || r.isLocked()) - return false; - for (LockableResource r : resources) { - r.setQueued(queueItemId, queueProjectName); - } - return true; - } - - /** - * @deprecated USe {@link #tryQueue(org.jenkins.plugins.lockableresources.queue.LockableResourcesStruct, long, java.lang.String, int, java.util.Map, java.util.logging.Logger)} - */ - @Deprecated - @CheckForNull - public synchronized List queue(LockableResourcesStruct requiredResources, - long queueItemId, - String queueItemProject, - int number, // 0 means all - Map params, - Logger log) { - try { - return tryQueue(requiredResources, queueItemId, queueItemProject, number, params, log); - } catch(ExecutionException ex) { - if (LOGGER.isLoggable(Level.WARNING)) { - String itemName = queueItemProject + " (id=" + queueItemId + ")"; - LOGGER.log(Level.WARNING, "Failed to queue item " + itemName, ex.getCause() != null ? ex.getCause() : ex); - } - return null; - } - } - - /** - * Try to acquire the resources required by the task. - * @param number Number of resources to acquire. {@code 0} means all - * @return List of the locked resources if the task has been accepted. - * {@code null} if the item is still waiting for the resources - * @throws ExecutionException Cannot queue the resource due to the execution failure. Carries info in the cause - * @since TODO - */ - @CheckForNull - public synchronized List tryQueue(LockableResourcesStruct requiredResources, - long queueItemId, String queueItemProject, int number, - Map params, Logger log) throws ExecutionException { - List selected = new ArrayList(); - - if (!checkCurrentResourcesStatus(selected, queueItemProject, queueItemId, log)) { - // The project has another buildable item waiting -> bail out - log.log(Level.FINEST, "{0} has another build waiting resources." + - " Waiting for it to proceed first.", - new Object[]{queueItemProject}); - return null; - } - - boolean candidatesByScript=false; - List candidates = new ArrayList(); - final SecureGroovyScript systemGroovyScript = requiredResources.getResourceMatchScript(); - if (requiredResources.label != null && requiredResources.label.isEmpty() && systemGroovyScript == null) { - candidates = requiredResources.required; - } else if (systemGroovyScript == null) { - candidates = getResourcesWithLabel(requiredResources.label, params); - } else { - candidates = getResourcesMatchingScript(systemGroovyScript, params); - candidatesByScript = true; - } - - for (LockableResource rs : candidates) { - if (number != 0 && (selected.size() >= number)) - break; - if (!rs.isReserved() && !rs.isLocked() && !rs.isQueued()) - selected.add(rs); - } - - // if did not get wanted amount or did not get all - final int required_amount; - if (candidatesByScript && candidates.size() == 0) { - /** - * If the groovy script does not return any candidates, it means nothing is needed, even - * if a higher amount is specified. A valid use case is a Matrix job, when not all - * configurations need resources. - */ - required_amount = 0; - } else { - required_amount = number == 0 ? candidates.size() : number; - } - - if (selected.size() != required_amount) { - log.log(Level.FINEST, "{0} found {1} resource(s) to queue." + - "Waiting for correct amount: {2}.", - new Object[]{queueItemProject, selected.size(), required_amount}); - // just to be sure, clean up - for (LockableResource x : resources) { - if (x.getQueueItemProject() != null && - x.getQueueItemProject().equals(queueItemProject)) - x.unqueue(); - } - return null; - } - - for (LockableResource rsc : selected) { - rsc.setQueued(queueItemId, queueItemProject); - } - return selected; - } - - // Adds already selected (in previous queue round) resources to 'selected' - // Return false if another item queued for this project -> bail out - private boolean checkCurrentResourcesStatus(List selected, - String project, - long taskId, - Logger log) { - for (LockableResource r : resources) { - // This project might already have something in queue - String rProject = r.getQueueItemProject(); - if (rProject != null && rProject.equals(project)) { - if (r.isQueuedByTask(taskId)) { - // this item has queued the resource earlier - selected.add(r); - } else { - // The project has another buildable item waiting -> bail out - log.log(Level.FINEST, "{0} has another build " + - "that already queued resource {1}. Continue queueing.", - new Object[]{project, r}); - return false; - } - } - } - return true; - } - - public synchronized boolean lock(Set resources, Run build, @Nullable StepContext context) { - return lock(resources, build, context, null, null, false); - } - - /** - * Try to lock the resource and return true if locked. - */ - public synchronized boolean lock(Set resources, - Run build, @Nullable StepContext context, @Nullable String logmessage, - final String variable, boolean inversePrecedence) { - boolean needToWait = false; - - for (LockableResource r : resources) { - if (r.isReserved() || r.isLocked()) { - needToWait = true; - break; - } - } - if (!needToWait) { - for (LockableResource r : resources) { - r.unqueue(); - r.setBuild(build); - } - if (context != null) { - // since LockableResource contains transient variables, they cannot be correctly serialized - // hence we use their unique resource names - List resourceNames = new ArrayList(); - for (LockableResource resource : resources) { - resourceNames.add(resource.getName()); - } - LockStepExecution.proceed(resourceNames, context, logmessage, variable, inversePrecedence); - } - } - save(); - return !needToWait; - } - - private synchronized void freeResources(List unlockResourceNames, @Nullable Run build) { - for (String unlockResourceName : unlockResourceNames) { - for (LockableResource resource : this.resources) { - if (resource != null && resource.getName() != null && resource.getName().equals(unlockResourceName)) { - if (build == null || (resource.getBuild() != null && build.getExternalizableId().equals(resource.getBuild().getExternalizableId()))) { - // No more contexts, unlock resource - resource.unqueue(); - resource.setBuild(null); - } - } - } - } - } - - public synchronized void unlock(List resourcesToUnLock, @Nullable Run build) { - unlock(resourcesToUnLock, build, null, false); - } - - public synchronized void unlock(@Nullable List resourcesToUnLock, - @Nullable Run build, String requiredVar, boolean inversePrecedence) { - List resourceNamesToUnLock = new ArrayList(); - if (resourcesToUnLock != null) { - for (LockableResource r : resourcesToUnLock) { - resourceNamesToUnLock.add(r.getName()); - } - } - - this.unlockNames(resourceNamesToUnLock, build, requiredVar, inversePrecedence); - } - - public synchronized void unlockNames(@Nullable List resourceNamesToUnLock, @Nullable Run build, String requiredVar, boolean inversePrecedence) { - // make sure there is a list of resource names to unlock - if (resourceNamesToUnLock == null || (resourceNamesToUnLock.size() == 0)) { - return; - } - - // process as many contexts as possible - List remainingResourceNamesToUnLock = new ArrayList<>(resourceNamesToUnLock); - - QueuedContextStruct nextContext = null; - while (!remainingResourceNamesToUnLock.isEmpty()) { - // check if there are resources which can be unlocked (and shall not be unlocked) - Set requiredResourceForNextContext = null; - nextContext = this.getNextQueuedContext(remainingResourceNamesToUnLock, inversePrecedence, nextContext); - - // no context is queued which can be started once these resources are free'd. - if (nextContext == null) { - this.freeResources(remainingResourceNamesToUnLock, build); - save(); - return; - } - - requiredResourceForNextContext = checkResourcesAvailability(nextContext.getResources(), null, remainingResourceNamesToUnLock); - - // resourceNamesToUnlock contains the names of the previous resources. - // requiredResourceForNextContext contains the resource objects which are required for the next context. - // It is guaranteed that there is an overlap between the two - the resources which are to be reused. - boolean needToWait = false; - for (LockableResource requiredResource : requiredResourceForNextContext) { - if (!remainingResourceNamesToUnLock.contains(requiredResource.getName())) { - if (requiredResource.isReserved() || requiredResource.isLocked()) { - needToWait = true; - break; - } - } - } - - if (!needToWait) { - // remove context from queue and process it - unqueueContext(nextContext.getContext()); - - List resourceNamesToLock = new ArrayList(); - - // lock all (old and new resources) - for (LockableResource requiredResource : requiredResourceForNextContext) { - try { - requiredResource.setBuild(nextContext.getContext().get(Run.class)); - resourceNamesToLock.add(requiredResource.getName()); - } catch (Exception e) { - // skip this context, as the build cannot be retrieved (maybe it was deleted while running?) - LOGGER.log(Level.WARNING, "Skipping queued context for lock. Can not get the Run object from the context to proceed with lock, " + - "this could be a legitimate status if the build waiting for the lock was deleted or" + - " hard killed. More information at Level.FINE if debug is needed."); - LOGGER.log(Level.FINE, "Can not get the Run object from the context to proceed with lock", e); - unlockNames(remainingResourceNamesToUnLock, build, requiredVar, inversePrecedence); - return; - } - } - - // determine old resources no longer needed - List freeResources = new ArrayList(); - for (String resourceNameToUnlock : remainingResourceNamesToUnLock) { - boolean resourceStillNeeded = false; - for (LockableResource requiredResource : requiredResourceForNextContext) { - if (resourceNameToUnlock != null && resourceNameToUnlock.equals(requiredResource.getName())) { - resourceStillNeeded = true; - break; - } - } - - if (!resourceStillNeeded) { - freeResources.add(resourceNameToUnlock); - } - } - - // keep unused resources - remainingResourceNamesToUnLock.retainAll(freeResources); - - // continue with next context - LockStepExecution.proceed(resourceNamesToLock, nextContext.getContext(), nextContext.getResourceDescription(), requiredVar, inversePrecedence); - } - } - save(); - } - - /** - * Returns the next queued context with all its requirements satisfied. - * - * @param resourceNamesToUnLock resource names locked at the moment but available is required (as they are going to be unlocked soon - * @param inversePrecedence false pick up context as they are in the queue or true to take the most recent one (satisfying requirements) - * @return the context or null - */ - @CheckForNull - private QueuedContextStruct getNextQueuedContext(List resourceNamesToUnLock, boolean inversePrecedence, QueuedContextStruct from) { - QueuedContextStruct newestEntry = null; - List requiredResourceForNextContext = null; - int fromIndex = from != null ? this.queuedContexts.indexOf(from) + 1 : 0; - if (!inversePrecedence) { - for (int i = fromIndex; i < this.queuedContexts.size(); i++) { - QueuedContextStruct entry = this.queuedContexts.get(i); - if (checkResourcesAvailability(entry.getResources(), null, resourceNamesToUnLock) != null) { - return entry; - } - } - } else { - long newest = 0; - List orphan = new ArrayList(); - for (int i = fromIndex; i < this.queuedContexts.size(); i++) { - QueuedContextStruct entry = this.queuedContexts.get(i); - if (checkResourcesAvailability(entry.getResources(), null, resourceNamesToUnLock) != null) { - try { - Run run = entry.getContext().get(Run.class); - if (run != null && run.getStartTimeInMillis() > newest) { - newest = run.getStartTimeInMillis(); - newestEntry = entry; - } - } catch (IOException | InterruptedException e) { - // skip this one, for some reason there is no Run object for this context - orphan.add(entry); - } - } - } - if (!orphan.isEmpty()) { - this.queuedContexts.removeAll(orphan); - } - } - - return newestEntry; - } - - /** - * Creates the resource if it does not exist. - */ - public synchronized boolean createResource(String name) { - if (name != null) { - LockableResource existent = fromName(name); - if (existent == null) { - getResources().add(new LockableResource(name)); - save(); - return true; - } - } - return false; - } - - public synchronized boolean createResourceWithLabel(String name, String label) { - if (name !=null && label !=null) { - LockableResource existent = fromName(name); - if (existent == null) { - getResources().add(new LockableResource(name, "", label, null)); - save(); - return true; - } - } - return false; - } - - public synchronized boolean reserve(List resources, - String userName) { - for (LockableResource r : resources) { - if (r.isReserved() || r.isLocked() || r.isQueued()) { - return false; - } - } - for (LockableResource r : resources) { - r.setReservedBy(userName); - } - save(); - return true; - } - - private void unreserveResources(@Nonnull List resources) { - for (LockableResource l : resources) { - l.unReserve(); - } - save(); - } - public synchronized void unreserve(List resources) { - // make sure there is a list of resources to unreserve - if (resources == null || (resources.size() == 0)) { - return; - } - List resourceNamesToUnreserve = new ArrayList<>(); - for (LockableResource r : resources) { - resourceNamesToUnreserve.add(r.getName()); - } - - // check if there are resources which can be unlocked (and shall not be unlocked) - Set requiredResourceForNextContext = null; - QueuedContextStruct nextContext = this.getNextQueuedContext(resourceNamesToUnreserve, false, null); - - // no context is queued which can be started once these resources are free'd. - if (nextContext == null) { - LOGGER.log(Level.FINER, "No context queued for resources " + StringUtils.join(resourceNamesToUnreserve, ", ") + " so unreserving and proceeding."); - unreserveResources(resources); - return; - } - - PrintStream nextContextLogger = null; - try { - TaskListener nextContextTaskListener = nextContext.getContext().get(TaskListener.class); - if (nextContextTaskListener != null) { - nextContextLogger = nextContextTaskListener.getLogger(); - } - } catch (IOException | InterruptedException e) { - LOGGER.log(Level.FINE, "Could not get logger for next context: " + e, e); - } - - // remove context from queue and process it - requiredResourceForNextContext = checkResourcesAvailability(nextContext.getResources(), - nextContextLogger, - resourceNamesToUnreserve); - this.queuedContexts.remove(nextContext); - - // resourceNamesToUnreserve contains the names of the previous resources. - // requiredResourceForNextContext contains the resource objects which are required for the next context. - // It is guaranteed that there is an overlap between the two - the resources which are to be reused. - boolean needToWait = false; - for (LockableResource requiredResource : requiredResourceForNextContext) { - if (!resourceNamesToUnreserve.contains(requiredResource.getName())) { - if (requiredResource.isReserved() || requiredResource.isLocked()) { - needToWait = true; - break; - } - } - } - - if (needToWait) { - unreserveResources(resources); - return; - } else { - unreserveResources(resources); - List resourceNamesToLock = new ArrayList(); - - // lock all (old and new resources) - for (LockableResource requiredResource : requiredResourceForNextContext) { - try { - requiredResource.setBuild(nextContext.getContext().get(Run.class)); - resourceNamesToLock.add(requiredResource.getName()); - } catch (Exception e) { - // skip this context, as the build cannot be retrieved (maybe it was deleted while running?) - LOGGER.log(Level.WARNING, "Skipping queued context for lock. Can not get the Run object from the context to proceed with lock, " + - "this could be a legitimate status if the build waiting for the lock was deleted or" + - " hard killed. More information at Level.FINE if debug is needed."); - LOGGER.log(Level.FINE, "Can not get the Run object from the context to proceed with lock", e); - return; - } - } - - // continue with next context - LockStepExecution.proceed(resourceNamesToLock, nextContext.getContext(), nextContext.getResourceDescription(), null, false); - } - save(); - } - - @Override - public String getDisplayName() { - return "External Resources"; - } - - public synchronized void reset(List resources) { - for (LockableResource r : resources) { - r.reset(); - } - save(); - } - - @Override - public boolean configure(StaplerRequest req, JSONObject json) - throws FormException { - try { - List newResouces = req.bindJSONToList( - LockableResource.class, json.get("resources")); - for (LockableResource r : newResouces) { - LockableResource old = fromName(r.getName()); - if (old != null) { - r.setBuild(old.getBuild()); - r.setQueued(r.getQueueItemId(), r.getQueueItemProject()); - } - } - resources = newResouces; - save(); - return true; - } catch (JSONException e) { - return false; - } - } - - /** - * Checks if there are enough resources available to satisfy the requirements specified - * within requiredResources and returns the necessary available resources. - * If not enough resources are available, returns null. - */ - public synchronized Set checkResourcesAvailability(List requiredResourcesList, - @Nullable PrintStream logger, @Nullable List lockedResourcesAboutToBeUnlocked) { - - List requiredResourcesCandidatesList = new ArrayList<>(); - - // Build possible resources for each requirement - for (LockableResourcesStruct requiredResources : requiredResourcesList) { - // get possible resources - int requiredAmount = 0; // 0 means all - List candidates = new ArrayList<>(); - if (requiredResources.label != null && requiredResources.label.isEmpty()) { - candidates.addAll(requiredResources.required); - } else { - candidates.addAll(getResourcesWithLabel(requiredResources.label, null)); - if (requiredResources.requiredNumber != null) { - try { - requiredAmount = Integer.parseInt(requiredResources.requiredNumber); - } catch (NumberFormatException e) { - requiredAmount = 0; - } - } - } - - if (requiredAmount == 0) { - requiredAmount = candidates.size(); - } - - requiredResourcesCandidatesList.add(new LockableResourcesCandidatesStruct(candidates, requiredAmount)); - } - - // Process freed resources - int totalSelected = 0; - - for (LockableResourcesCandidatesStruct requiredResources : requiredResourcesCandidatesList) { - // start with an empty set of selected resources - List selected = new ArrayList(); - - // some resources might be already locked, but will be freed. - // Determine if these resources can be reused - if (lockedResourcesAboutToBeUnlocked != null) { - for (LockableResource candidate : requiredResources.candidates) { - if (selected.size() >= requiredResources.requiredAmount) { - break; - } - if (lockedResourcesAboutToBeUnlocked.contains(candidate.getName())) { - selected.add(candidate); - } - } - } - - totalSelected += selected.size(); - requiredResources.selected = selected; - } - - // if none of the currently locked resources can be reused, - // this context is not suitable to be continued with - if (lockedResourcesAboutToBeUnlocked != null && totalSelected == 0) { - return null; - } - - // Find remaining resources - Set allSelected = new HashSet<>(); - - for (LockableResourcesCandidatesStruct requiredResources : requiredResourcesCandidatesList) { - List candidates = requiredResources.candidates; - List selected = requiredResources.selected; - int requiredAmount = requiredResources.requiredAmount; - - // Try and re-use as many previously selected resources first - List alreadySelectedCandidates = new ArrayList<>(candidates); - alreadySelectedCandidates.retainAll(allSelected); - for (LockableResource rs : alreadySelectedCandidates) { - if (selected.size() >= requiredAmount) { - break; - } - if (!rs.isReserved() && !rs.isLocked()) { - selected.add(rs); - } - } - - candidates.removeAll(alreadySelectedCandidates); - for (LockableResource rs : candidates) { - if (selected.size() >= requiredAmount) { - break; - } - if (!rs.isReserved() && !rs.isLocked()) { - selected.add(rs); - } - } - - if (selected.size() < requiredAmount) { - if (logger != null) { - logger.println("Found " + selected.size() + " available resource(s). Waiting for correct amount: " + requiredAmount + "."); - } - return null; - } - - allSelected.addAll(selected); - } - - return allSelected; - } - - /* - * Adds the given context and the required resources to the queue if - * this context is not yet queued. - */ - public synchronized void queueContext(StepContext context, List requiredResources, String resourceDescription) { - for (QueuedContextStruct entry : this.queuedContexts) { - if (entry.getContext() == context) { - return; - } - } - - this.queuedContexts.add(new QueuedContextStruct(context, requiredResources, resourceDescription)); - save(); - } - - public synchronized boolean unqueueContext(StepContext context) { - for (Iterator iter = this.queuedContexts.listIterator(); iter.hasNext(); ) { - if (iter.next().getContext() == context) { - iter.remove(); - save(); - return true; - } - } - return false; - } - - public static LockableResourcesManager get() { - return (LockableResourcesManager) Jenkins.getInstance() - .getDescriptorOrDie(LockableResourcesManager.class); - } - - public synchronized void save() { - if(BulkChange.contains(this)) + /** Object to synchronized operations over LRM */ + public static final Object syncResources = new Object(); + + private List resources; + + /** + * Remote connections are persisted as a list for GlobalConfiguration/XStream compatibility. + */ + private List remotes; + + private final transient Cache> cachedCandidates = + CacheBuilder.newBuilder().expireAfterWrite(5, TimeUnit.MINUTES).build(); + private static final Logger LOGGER = Logger.getLogger(LockableResourcesManager.class.getName()); + + private boolean allowEmptyOrNullValues; + + /** + * Controls whether ephemeral resources can be created automatically. + * When enabled (default), locking a non-existent resource creates it dynamically. + * When disabled, locking a non-existent resource will block until it is manually created. + */ + private boolean allowEphemeralResources = true; + + /** + * Only used when this lockable resource is tried to be locked by {@link LockStep}, otherwise + * (freestyle builds) regular Jenkins queue is used. + */ + private final List queuedContexts = new ArrayList<>(); + + /** + * Remote acquire requests waiting for resources. Transient: does not survive Jenkins restart + * (remote locks are expected to be released before restart per operator runbook). + * All access must be under {@link #syncResources}. + */ + private transient List remoteQueueEntries; + + // cache to enable / disable saving lockable-resources state + private int enableSave = -1; + + /** When true the remote lock REST API (/remote/v1/*) is active on this server. */ + private boolean remoteApiEnabled = false; + + /** + * Only resources that carry this label are exposed via the remote API. + * When empty or null, no resources are exposed (opt-in: must explicitly set a label). + */ + private String exposeLabel = ""; + + /** + * Client identifier sent to remote servers in POST /acquire. + * When empty the Jenkins root URL is used as a fallback. + */ + private String clientId = ""; + + /** + * When non-empty, every {@code lock()} step on this controller is transparently delegated + * to the remote server identified by this serverId (delegated mode). Explicit + * {@code serverId} in the DSL is overridden by this setting (an INFO log is emitted). + */ + private String forcedServerId = ""; + + private static final int enabledBlockedCount = + SystemProperties.getInteger(Constants.SYSTEM_PROPERTY_PRINT_BLOCKED_RESOURCE, 2); + private static final int enabledCausesCount = + SystemProperties.getInteger(Constants.SYSTEM_PROPERTY_PRINT_QUEUE_INFO, 2); + + private static final boolean asyncSaveEnabled = + SystemProperties.getBoolean(Constants.SYSTEM_PROPERTY_ASYNC_SAVE, true); + private static final long saveCoalesceMs = + SystemProperties.getLong(Constants.SYSTEM_PROPERTY_SAVE_COALESCE_MS, 1000L); + + private transient volatile AtomicBoolean savePending; + private transient volatile ScheduledExecutorService saveExecutor; + + /** Single scheduled timeout task. Guarded by {@link #syncResources}. */ + private transient java.util.concurrent.ScheduledFuture nextTimeoutTask; + + /** Deadline (epoch ms) the current {@link #nextTimeoutTask} targets. 0 = none. */ + private transient long nextTimeoutDeadline; + + @DataBoundSetter + public void setAllowEmptyOrNullValues(boolean allowEmptyOrNullValues) { + this.allowEmptyOrNullValues = allowEmptyOrNullValues; + } + + public boolean isAllowEmptyOrNullValues() { + return allowEmptyOrNullValues; + } + + /** + * Sets whether ephemeral resources can be created automatically. + * + * @param allowEphemeralResources true to allow automatic creation of ephemeral resources + */ + @DataBoundSetter + public void setAllowEphemeralResources(boolean allowEphemeralResources) { + this.allowEphemeralResources = allowEphemeralResources; + } + + /** + * Returns whether ephemeral resources are allowed. + * + * @return true if ephemeral resources can be created automatically + */ + public boolean isAllowEphemeralResources() { + return allowEphemeralResources; + } + + // --------------------------------------------------------------------------- + /** C-tor */ + @SuppressFBWarnings( + value = "MC_OVERRIDABLE_METHOD_CALL_IN_CONSTRUCTOR", + justification = "Common Jenkins pattern to call method that can be overridden") + public LockableResourcesManager() { + resources = new ArrayList<>(); + remotes = new ArrayList<>(); + load(); + } + + /** + * Called by XStream after deserialization to restore defaults for newly added fields. + */ + private Object readResolve() { + if (remotes == null) { + remotes = new ArrayList<>(); + } + if (exposeLabel == null) { + exposeLabel = ""; + } + if (clientId == null) { + clientId = ""; + } + if (forcedServerId == null) { + forcedServerId = ""; + } + return this; + } + + @DataBoundSetter + public void setRemoteApiEnabled(boolean remoteApiEnabled) { + this.remoteApiEnabled = remoteApiEnabled; + } + + public boolean isRemoteApiEnabled() { + return remoteApiEnabled; + } + + @DataBoundSetter + public void setExposeLabel(String exposeLabel) { + this.exposeLabel = exposeLabel != null ? exposeLabel : ""; + } + + public String getExposeLabel() { + return exposeLabel; + } + + /** + * The configured {@code exposeLabel}(s) as a set. {@code exposeLabel} is a whitespace-separated list of + * labels (same convention as {@link LockableResource#getLabelsAsList()}); a resource is exposed to remote + * clients if it carries any of them (OR). An empty/blank value exposes nothing (opt-in). A single + * label is simply a one-element set, so existing single-label configurations behave unchanged. + */ + @NonNull + @Restricted(NoExternalUse.class) + public Set getExposeLabels() { + if (exposeLabel == null || exposeLabel.isBlank()) { + return Collections.emptySet(); + } + Set labels = new HashSet<>(); + for (String l : exposeLabel.split("\\s+")) { + if (!l.isEmpty()) { + labels.add(l); + } + } + return labels; + } + + @DataBoundSetter + public void setClientId(String clientId) { + this.clientId = clientId != null ? clientId.trim() : ""; + } + + public String getClientId() { + return clientId; + } + + @DataBoundSetter + public void setForcedServerId(String forcedServerId) { + this.forcedServerId = forcedServerId != null ? forcedServerId.trim() : ""; + } + + public String getForcedServerId() { + return forcedServerId; + } + + /** + * Form validation for {@code forcedServerId}: warns when the value does not match + * any configured remote connection (delegated mode would fail at lock() time). + */ + @POST + public FormValidation doCheckForcedServerId(@QueryParameter String value) { + Jenkins.get().checkPermission(Jenkins.ADMINISTER); + String trimmed = Util.fixEmptyAndTrim(value); + if (trimmed == null) { + return FormValidation.ok(); + } + if (!getRemotesAsMap().containsKey(trimmed)) { + return FormValidation.warning(Messages.warning_forcedServerIdNotConfigured(trimmed)); + } + return FormValidation.ok(); + } + + /** + * Returns the effective client identifier to send to remote servers. + * Uses the configured clientId if non-empty; otherwise falls back + * to Jenkins.getRootUrl() (which may be null if not configured). + */ + @edu.umd.cs.findbugs.annotations.CheckForNull + public String getEffectiveClientId() { + if (clientId != null && !clientId.isEmpty()) { + return clientId; + } + return Jenkins.get().getRootUrl(); + } + + // --------------------------------------------------------------------------- + /** Get all resources Includes declared, ephemeral and node resources */ + public List getResources() { + return this.resources; + } + + // --------------------------------------------------------------------------- + /** Returns a defensive copy of configured remote connections. */ + public List getRemotes() { + return remotes != null ? new ArrayList<>(remotes) : new ArrayList<>(); + } + + // --------------------------------------------------------------------------- + /** + * Returns remote connections as a map keyed by serverId. + * + *

Duplicate serverIds are resolved as last-entry-wins to match form submission order. + */ + public Map getRemotesAsMap() { + LinkedHashMap remotesByServerId = new LinkedHashMap<>(); + for (RemoteConnection remote : getRemotes()) { + if (remote == null) { + continue; + } + String serverId = remote.getServerId(); + if (serverId == null || serverId.isEmpty()) { + continue; + } + remotesByServerId.put(serverId, remote); + } + return Collections.unmodifiableMap(remotesByServerId); + } + + // --------------------------------------------------------------------------- + /** Set all remote connections. */ + @DataBoundSetter + public void setRemotes(List remotes) { + ArrayList validatedRemotes = new ArrayList<>(); + if (remotes != null) { + HashSet seenServerIds = new HashSet<>(); + for (RemoteConnection remote : remotes) { + if (remote == null) { + throw new IllegalArgumentException("remote connection entry must not be null"); + } + remote.validate(); + + String serverId = remote.getServerId(); + if (!seenServerIds.add(serverId)) { + LOGGER.warning("Duplicate remote serverId detected: " + serverId + ". Last entry wins."); + } + validatedRemotes.add(remote); + } + } + this.remotes = validatedRemotes; + save(); + } + + // --------------------------------------------------------------------------- + /** + * Get all resources - read only The same as getResources() but unmodifiable list. The + * getResources() is unsafe to use because of possible concurrent modification exception. + */ + @Restricted(NoExternalUse.class) + public List getReadOnlyResources() { + synchronized (syncResources) { + return new ArrayList<>(Collections.unmodifiableCollection(this.resources)); + } + } + + // --------------------------------------------------------------------------- + /** Get declared resources, means only defined in config file (xml or JCaC yaml). */ + @Restricted(NoExternalUse.class) + public List getDeclaredResources() { + synchronized (syncResources) { + ArrayList declaredResources = new ArrayList<>(); + for (LockableResource r : this.resources) { + if (!r.isEphemeral() && !r.isNodeResource()) { + declaredResources.add(r); + } + } + return declaredResources; + } + } + + // --------------------------------------------------------------------------- + /** Set all declared resources (do not include ephemeral and node resources). */ + @DataBoundSetter + public void setDeclaredResources(List declaredResources) { + synchronized (syncResources) { + Map lockedResources = new HashMap<>(); + for (LockableResource r : this.resources) { + if (!r.isLocked()) continue; + lockedResources.put(r.getName(), r); + } + + // Removed from configuration locks became ephemeral. + ArrayList mergedResources = new ArrayList<>(); + Set addedLocks = new HashSet<>(); + for (LockableResource r : declaredResources) { + if (!addedLocks.add(r.getName())) { + continue; + } + LockableResource locked = lockedResources.remove(r.getName()); + if (locked != null) { + // Merge already locked lock. + locked.setDescription(r.getDescription()); + locked.setLabelsFromString(r.getLabelsAsString()); + locked.setEphemeral(false); + locked.setNote(r.getNote()); + mergedResources.add(locked); + continue; + } + mergedResources.add(r); + } + + for (LockableResource r : lockedResources.values()) { + // Removed locks became ephemeral. + r.setDescription(""); + r.setLabelsFromString(""); + r.setNote(""); + r.setEphemeral(true); + mergedResources.add(r); + } + + // Copy reservations and unconfigurable properties from old instances. Clear unconfigurable + // properties for new resources: they should be empty anyway for new resources from UI + // configuration. For CasC configuration, we ignore those fields, so set them to empty. + for (LockableResource newResource : mergedResources) { + final LockableResource oldDeclaredResource = fromName(newResource.getName()); + if (oldDeclaredResource != null) { + newResource.copyUnconfigurableProperties(oldDeclaredResource); + } else { + newResource.resetUnconfigurableProperties(); + } + } + + this.resources = mergedResources; + save(); + } + } + + // --------------------------------------------------------------------------- + /** Get all resources used by project. */ + @Restricted(NoExternalUse.class) + public List getResourcesFromProject(String fullName) { + synchronized (syncResources) { + List matching = new ArrayList<>(); + for (LockableResource r : this.resources) { + String rName = r.getQueueItemProject(); + if (rName != null && rName.equals(fullName)) { + matching.add(r); + } + } + return matching; + } + } + + // --------------------------------------------------------------------------- + /** + * Check if the label is valid. Valid in this context means, if is configured on someone resource. + */ + @Restricted(NoExternalUse.class) + public Boolean isValidLabel(@Nullable String label) { + if (label == null || label.isEmpty()) { + return false; + } + + synchronized (syncResources) { + for (LockableResource r : this.getResources()) { + if (r != null && r.isValidLabel(label)) { + return true; + } + } + } + + return false; + } + + // --------------------------------------------------------------------------- + /** Returns all configured labels. */ + @NonNull + @Restricted(NoExternalUse.class) + public Set getAllLabels() { + Set labels = new HashSet<>(); + for (LockableResource r : this.getReadOnlyResources()) { + if (r == null) { + continue; + } + List toAdd = r.getLabelsAsList(); + if (toAdd.isEmpty()) { + continue; + } + labels.addAll(toAdd); + } + return labels; + } + + // --------------------------------------------------------------------------- + /** Get amount of free resources contained given *label* + * This method is deprecated (no where used) and is not tested. + */ + @Restricted(NoExternalUse.class) + @Deprecated + @ExcludeFromJacocoGeneratedReport + public int getFreeResourceAmount(String label) { + int free = 0; + label = Util.fixEmpty(label); + + if (label == null) { + return free; + } + + for (LockableResource r : this.getResourcesWithLabel(label)) { + if (r == null) { + continue; + } + if (r.isFree()) { + free++; + } + } + return free; + } + + // --------------------------------------------------------------------------- + /** + * @deprecated Use getResourcesWithLabel(String label) + * Note: The param *params* is not used (has no effect) + */ + @Deprecated + @Restricted(NoExternalUse.class) + @ExcludeFromJacocoGeneratedReport + public List getResourcesWithLabel(String label, Map params) { + return getResourcesWithLabel(label); + } + + // --------------------------------------------------------------------------- + /** + * Returns resources matching by given *label*. + */ + @NonNull + @Restricted(NoExternalUse.class) + public List getResourcesWithLabel(final String label) { + synchronized (syncResources) { + return _getResourcesWithLabel(label, this.getResources()); + } + } + + // --------------------------------------------------------------------------- + @NonNull + private static List _getResourcesWithLabel(String label, final List resources) { + List found = new ArrayList<>(); + label = Util.fixEmpty(label); + + if (label == null) { + return found; + } + + for (LockableResource r : resources) { + if (r != null && r.isValidLabel(label)) found.add(r); + } + return found; + } + + // --------------------------------------------------------------------------- + /** + * Returns a list of resources matching by given *script*. + * + * @param script Script + * @param params Additional parameters + * @return List of the matching resources + * @throws ExecutionException Script execution failed for one of the resources. It is considered + * as a fatal failure since the requirement list may be incomplete + * @since 2.0 + */ + @NonNull + @Restricted(NoExternalUse.class) + public List getResourcesMatchingScript( + @NonNull SecureGroovyScript script, @CheckForNull Map params) throws ExecutionException { + List found = new ArrayList<>(); + synchronized (syncResources) { + for (LockableResource r : this.resources) { + if (r.scriptMatches(script, params)) found.add(r); + } + } + return found; + } + + // --------------------------------------------------------------------------- + /** Returns resource matched by name. Returns null in case, the resource does not exist. */ + @CheckForNull + @Restricted(NoExternalUse.class) + public LockableResource fromName(@CheckForNull String resourceName) { + resourceName = Util.fixEmpty(resourceName); + + if (resourceName != null) { + + synchronized (syncResources) { + for (LockableResource r : this.getResources()) { + if (resourceName.equals(r.getName())) return r; + } + } + } else { + LOGGER.warning("Internal failure, fromName is empty or null:" + getStack()); + } + return null; + } + + // --------------------------------------------------------------------------- + @Restricted(NoExternalUse.class) + public List fromNames(@Nullable final List names) { + if (names == null) { + return null; + } + return fromNames(names, false); + } + + // --------------------------------------------------------------------------- + @Restricted(NoExternalUse.class) + public List fromNames(final List names, final boolean createResource) { + List list = new ArrayList<>(); + for (String name : names) { + // be sure it exists + if (createResource) this.createResource(name); + LockableResource r = this.fromName(name); + if (r != null) // this is probably bug, but nobody know + list.add(r); + } + return list; + } + + // --------------------------------------------------------------------------- + private String getStack() { + StringBuilder buf = new StringBuilder(); + for (StackTraceElement st : Thread.currentThread().getStackTrace()) { + buf.append("\n").append(st); + } + return buf.toString(); + } + + // --------------------------------------------------------------------------- + /** Checks if given resource exist. */ + @NonNull + @Restricted(NoExternalUse.class) + public Boolean resourceExist(@CheckForNull String resourceName) { + return this.fromName(resourceName) != null; + } + + // --------------------------------------------------------------------------- + public boolean queue(List resources, long queueItemId, String queueProjectName) { + for (LockableResource r : resources) { + if (r.isReserved() || r.isQueued(queueItemId) || r.isLocked()) { + return false; + } + } + for (LockableResource r : resources) { + r.setQueued(queueItemId, queueProjectName); + } + return true; + } + + // --------------------------------------------------------------------------- + /** + * @deprecated Use {@link + * #tryQueue(org.jenkins.plugins.lockableresources.queue.LockableResourcesStruct, long, + * java.lang.String, int, java.util.Map, java.util.logging.Logger)} + */ + @Deprecated + @CheckForNull + @ExcludeFromJacocoGeneratedReport + @Restricted(NoExternalUse.class) + public List queue( + LockableResourcesStruct requiredResources, + long queueItemId, + String queueItemProject, + int number, // 0 means all + Map params, + Logger log) { + try { + return tryQueue(requiredResources, queueItemId, queueItemProject, number, params, log); + } catch (ExecutionException ex) { + if (LOGGER.isLoggable(Level.WARNING)) { + String itemName = queueItemProject + " (id=" + queueItemId + ")"; + LOGGER.log( + Level.WARNING, "Failed to queue item " + itemName, ex.getCause() != null ? ex.getCause() : ex); + } + return null; + } + } + + // --------------------------------------------------------------------------- + /** + * If the lockable resource availability was evaluated before and cached to avoid frequent + * re-evaluations under queued pressure when there are no resources to give, we should state that + * a resource is again instantly available for re-evaluation when we know it was busy and right + * now is being freed. Note that a resource may be (both or separately) locked by a build and/or + * reserved by a user (or stolen from build to user) so we only un-cache it here if it becomes + * completely available. Called as a helper from methods that unlock/unreserve/reset (or + * indirectly - recycle) stuff. + * + *

NOTE for people using LR or LRM methods directly to add some abilities in their pipelines + * that are not provided by plugin: the `cachedCandidates` is an LRM concept, so if you tell a + * resource (LR instance) directly to unlock/unreserve, it has no idea to clean itself from this + * cache, and may be considered busy in queuing for some time afterward. + */ + public boolean uncacheIfFreeing(LockableResource candidate, boolean unlocking, boolean unreserving) { + if (candidate == null) return false; + if (candidate.isLocked() && !unlocking) return false; + + // "stolen" state helps track that a resource is currently not + // reserved for the same entity as it was originally given to; + // this flag is cleared during un-reservation. + if ((candidate.isReserved() || candidate.isStolen()) && !unreserving) return false; + + if (cachedCandidates.size() == 0) return true; + + // Take a snapshot of keys to avoid ConcurrentModificationException. + // Only invalidate entries that actually contain the freed resource, + // preserving cache for other queue items (important at scale with 1000+ items). + Set keys = new HashSet<>(cachedCandidates.asMap().keySet()); + for (Long queueItemId : keys) { + List candidates = cachedCandidates.getIfPresent(queueItemId); + if (candidates != null && (candidates.isEmpty() || candidates.contains(candidate))) { + cachedCandidates.invalidate(queueItemId); + } + } + + return true; + } + + // --------------------------------------------------------------------------- + /** + * Try to acquire the resources required by the task. + * + * @param number Number of resources to acquire. {@code 0} means all + * @return List of the locked resources if the task has been accepted. {@code null} if the item is + * still waiting for the resources + * @throws ExecutionException Cannot queue the resource due to the execution failure. Carries info + * in the cause + * @since 2.0 + */ + @CheckForNull + @Restricted(NoExternalUse.class) + public List tryQueue( + LockableResourcesStruct requiredResources, + long queueItemId, + String queueItemProject, + int number, + Map params, + Logger log) + throws ExecutionException { + + final SecureGroovyScript systemGroovyScript; + try { + systemGroovyScript = requiredResources.getResourceMatchScript(); + } catch (Descriptor.FormException x) { + throw new ExecutionException(x); + } + boolean candidatesByScript = (systemGroovyScript != null); + + // Resolve candidates outside syncResources when possible — label matching + // and Groovy script evaluation are heavyweight and should not extend the + // critical section. + // NOTE: We store unmodifiable lists in the cache for thread-safety. Multiple + // threads may read from the cache concurrently, so cached lists must not be + // modified. Create a mutable copy below when modifications are needed. + List candidates = null; + if (candidatesByScript || (requiredResources.label != null && !requiredResources.label.isEmpty())) { + candidates = cachedCandidates.getIfPresent(queueItemId); + if (candidates == null) { + candidates = (systemGroovyScript == null) + ? getResourcesWithLabel(requiredResources.label) + : getResourcesMatchingScript(systemGroovyScript, params); + // Store as unmodifiable to prevent accidental modification of cached data + cachedCandidates.put(queueItemId, Collections.unmodifiableList(candidates)); + } + } + + List selected = new ArrayList<>(); + synchronized (syncResources) { + if (!checkCurrentResourcesStatus(selected, queueItemProject, queueItemId, log)) { + // The project has another buildable item waiting -> bail out + log.log( + Level.FINEST, + "{0} has another build waiting resources." + " Waiting for it to proceed first.", + new Object[] {queueItemProject}); + return null; + } + + if (candidates != null) { + // Mutable copy required - cached list is unmodifiable for thread-safety + candidates = new ArrayList<>(candidates); + candidates.retainAll(this.resources); + } else { + candidates = requiredResources.required; + } + + for (LockableResource rs : candidates) { + if (number != 0 && (selected.size() >= number)) break; + if (!rs.isReserved() && !rs.isLocked() && !rs.isQueued()) selected.add(rs); + } + + // if did not get wanted amount or did not get all + final int required_amount = getRequiredAmount(number, candidatesByScript, candidates); + + if (selected.size() != required_amount) { + log.log( + Level.FINEST, + "{0} found {1} resource(s) to queue. Waiting for correct amount: {2}.", + new Object[] {queueItemProject, selected.size(), required_amount}); + // just to be sure, clean up + for (LockableResource x : this.resources) { + if (x.getQueueItemProject() != null + && x.getQueueItemProject().equals(queueItemProject)) x.unqueue(); + } + return null; + } + + for (LockableResource rsc : selected) { + rsc.setQueued(queueItemId, queueItemProject); + } + } + return selected; + } + + // --------------------------------------------------------------------------- + /** + * Returns the amount of resources required by the task. + * If the groovy script does not return any candidates, it means nothing is needed, even if a + * higher amount is specified. A valid use case is a Matrix job, when not all configurations need resources. + */ + private static int getRequiredAmount(int number, boolean candidatesByScript, List candidates) { + final int required_amount; + if (candidatesByScript && candidates.isEmpty()) { + required_amount = 0; + } else { + required_amount = number == 0 ? candidates.size() : number; + } + return required_amount; + } + + // --------------------------------------------------------------------------- + // Adds already selected (in previous queue round) resources to 'selected' + // Return false if another item queued for this project -> bail out + private boolean checkCurrentResourcesStatus( + List selected, String project, long taskId, Logger log) { + for (LockableResource r : this.resources) { + // This project might already have something in queue + String rProject = r.getQueueItemProject(); + if (rProject != null && rProject.equals(project)) { + if (r.isQueuedByTask(taskId)) { + // this item has queued the resource earlier + selected.add(r); + } else { + // The project has another buildable item waiting -> bail out + log.log( + Level.FINEST, + "{0} has another build that already queued resource {1}. Continue queueing.", + new Object[] {project, r}); + return false; + } + } + } + return true; + } + + // --------------------------------------------------------------------------- + @Deprecated + public boolean lock(List resources, Run build, @Nullable StepContext context) { + return this.lock(resources, build); + } + + // --------------------------------------------------------------------------- + @Deprecated + public boolean lock( + List resources, + Run build, + @Nullable StepContext context, + @Nullable String logmessage, + final String variable, + boolean inversePrecedence) { + return this.lock(resources, build); + } + + // --------------------------------------------------------------------------- + /** Try to lock the resource and return true if locked. */ + public boolean lock(List resourcesToLock, Run build) { + return lock(resourcesToLock, build, (String) null); + } + + // --------------------------------------------------------------------------- + /** + * Try to lock the resource and return true if locked. + * + * @param resourcesToLock The resources to lock. + * @param build The build that is locking the resources. + * @param reason The reason why the resources are being locked (displayed in UI). + * @return true if locked successfully. + */ + public boolean lock(List resourcesToLock, Run build, @Nullable String reason) { + + LOGGER.fine("lock it: " + resourcesToLock + " for build " + build + " with reason: " + reason); + + if (build == null) { + LOGGER.warning("lock() will fails, because the build does not exits. " + resourcesToLock); + return false; // not locked + } + + String cause = getCauses(resourcesToLock); + if (!cause.isEmpty()) { + LOGGER.warning("lock() for build " + build + " will fails, because " + cause); + return false; // not locked + } + + for (LockableResource r : resourcesToLock) { + r.unqueue(); + r.setBuild(build); + if (reason != null && !reason.isEmpty()) { + r.setLockReason(reason); + } + } + + LockedResourcesBuildAction.findAndInitAction(build).addUsedResources(getResourcesNames(resourcesToLock)); + + save(); + + return true; + } + + // --------------------------------------------------------------------------- + private void freeResources(List unlockResources, Run build) { + + LOGGER.fine("free it: " + unlockResources); + + // make sure there is a list of resource names to unlock + if (unlockResources == null || unlockResources.isEmpty() || build == null) { + return; + } + + List toBeRemoved = new ArrayList<>(); + + for (LockableResource resource : unlockResources) { + // No more contexts, unlock resource + + // the resource has been currently unlocked (like by LRM page - button unlock, or by API) + if (!build.equals(resource.getBuild())) continue; + + resource.unqueue(); + resource.setBuild(null); + resource.setLockReason(null); + uncacheIfFreeing(resource, true, false); + + if (resource.isEphemeral()) { + LOGGER.fine("Remove ephemeral resource: " + resource); + toBeRemoved.add(resource); + } + } + + LockedResourcesBuildAction.findAndInitAction(build).removeUsedResources(getResourcesNames(unlockResources)); + + // remove all ephemeral resources + removeResources(toBeRemoved); + } + + public void unlockBuild(@Nullable Run build) { + + if (build == null) { + return; + } + + List resourcesInUse = + LockedResourcesBuildAction.findAndInitAction(build).getCurrentUsedResourceNames(); + + if (resourcesInUse.isEmpty()) { + return; + } + unlockNames(resourcesInUse, build); + } + + // --------------------------------------------------------------------------- + public void unlockNames(@Nullable List resourceNamesToUnLock, Run build) { + + // make sure there is a list of resource names to unlock + if (resourceNamesToUnLock == null || resourceNamesToUnLock.isEmpty()) { + return; + } + synchronized (syncResources) { + unlockResources(this.fromNames(resourceNamesToUnLock), build); + } + } + + // --------------------------------------------------------------------------- + public void unlockResources(List resourcesToUnLock) { + unlockResources(resourcesToUnLock, resourcesToUnLock.get(0).getBuild()); + } + + // --------------------------------------------------------------------------- + public void unlockResources(List resourcesToUnLock, Run build) { + if (resourcesToUnLock == null || resourcesToUnLock.isEmpty()) { + return; + } + synchronized (syncResources) { + this.freeResources(resourcesToUnLock, build); + + while (proceedNextContext()) { + // process as many contexts as possible + } + + save(); + } + scheduleQueueMaintenance(); + } + + private boolean proceedNextContext() { + QueuedContextStruct nextLocal = this.getNextQueuedContext(); + RemoteQueueEntry nextRemote = this.getNextRemoteEntry(); + + if (nextLocal == null && nextRemote == null) { + LOGGER.fine("No context is queued which can be started once these resources are free'd."); + return false; + } + + // Process the higher-priority entry first; equal priority favours local (arrived earlier) + boolean pickRemote = + nextRemote != null && (nextLocal == null || nextRemote.getPriority() > nextLocal.getPriority()); + + if (pickRemote) { + return proceedRemoteEntry(nextRemote); + } else { + return proceedLocalEntry(nextLocal); + } + } + + private boolean proceedLocalEntry(@NonNull QueuedContextStruct nextContext) { + LOGGER.finest("nextContext candidates: " + nextContext.candidates); + List requiredResourceForNextContext = + this.fromNames(nextContext.candidates, /*create un-existent resources */ true); + LOGGER.finest("nextContext real candidates: " + requiredResourceForNextContext); + + Run build = nextContext.getBuild(); + if (build == null) { + // this shall never happen - skip context (build deleted while running?) + LOGGER.warning("Skip this context, as the build cannot be retrieved"); + return true; + } + boolean locked = this.lock(requiredResourceForNextContext, build, nextContext.getReason()); + if (!locked) { + // defensive; shall never happen + LOGGER.warning("Can not lock resources: " + requiredResourceForNextContext); + return false; + } + + LinkedHashMap> resourcesToLock = new LinkedHashMap<>(); + for (LockableResource r : requiredResourceForNextContext) { + resourcesToLock.put(r.getName(), r.getProperties()); + } + this.unqueueContext(nextContext.getContext()); + LOGGER.fine("Continue with next context: " + nextContext); + LockStepExecution.proceed( + resourcesToLock, + nextContext.getContext(), + nextContext.getResourceDescription(), + nextContext.getVariableName()); + return true; + } + + private boolean proceedRemoteEntry(@NonNull RemoteQueueEntry entry) { + List resources = entry.getResolved(); + if (resources == null || resources.isEmpty()) { + LOGGER.warning("Remote queue entry has no resolved resources: " + entry.getLockId()); + return false; + } + boolean locked = lockForRemote(resources, entry.getLockId()); + if (!locked) { + LOGGER.warning("Cannot lock remote resources: " + entry.getLockId()); + return false; + } + getRemoteQueueEntries().remove(entry); + // onAcquired marks the record ACQUIRED and builds lockEnvVars (incl. resource properties), + // using the shared LockStepExecution.buildLockEnvVars - same as local lock(). + entry.onAcquired(resources); + LOGGER.fine( + "Remote queued->acquired: lockId=" + entry.getLockId() + " resources=" + getResourcesNames(resources)); + return true; + } + + // --------------------------------------------------------------------------- + /** Returns names (IDs) of given *resources*. */ + @Restricted(NoExternalUse.class) + public static List getResourcesNames(final List resources) { + List resourceNames = new ArrayList<>(); + if (resources != null) { + for (LockableResource resource : resources) { + resourceNames.add(resource.getName()); + } + } + return resourceNames; + } + + // --------------------------------------------------------------------------- + /** Returns names (IDs) off all existing resources (inclusive ephemeral) */ + @Restricted(NoExternalUse.class) + public List getAllResourcesNames() { + synchronized (syncResources) { + return getResourcesNames(this.resources); + } + } + + // --------------------------------------------------------------------------- + /** + * Returns the next queued context with all its requirements satisfied. + * + */ + @CheckForNull + private QueuedContextStruct getNextQueuedContext() { + + LOGGER.fine("current queue size: " + this.queuedContexts.size()); + LOGGER.finest("current queue: " + this.queuedContexts); + List toRemove = new ArrayList<>(); + QueuedContextStruct nextEntry = null; + long earliestDeadline = Long.MAX_VALUE; + + // the first one added lock is the oldest one, and this wins + + for (int idx = 0; idx < this.queuedContexts.size() && nextEntry == null; idx++) { + QueuedContextStruct entry = this.queuedContexts.get(idx); + // check queue list first + if (!entry.isValid()) { + LOGGER.fine("well be removed: " + idx + " " + entry); + toRemove.add(entry); + continue; + } + + // check if the entry has timed out waiting for resources + if (entry.isTimedOut()) { + LOGGER.info("Queue entry timed out waiting for resources: " + entry); + toRemove.add(entry); + PrintStream logger = entry.getLogger(); + String msg = "[" + entry.getResourceDescription() + + "] timed out waiting for resource allocation after " + + entry.getTimeoutForAllocateResource() + " " + + entry.getTimeoutUnit().toLowerCase(java.util.Locale.ENGLISH); + printLogs(msg, logger, Level.WARNING); + entry.getContext() + .onFailure(new org.jenkins.plugins.lockableresources.queue.LockWaitTimeoutException(msg)); + continue; + } + + // track the earliest deadline among remaining entries for rescheduling + long deadline = entry.getTimeoutDeadlineMillis(); + if (deadline > 0 && deadline < earliestDeadline) { + earliestDeadline = deadline; + } + + LOGGER.finest("oldest win - index: " + idx + " " + entry); + + nextEntry = getNextQueuedContextEntry(entry); + } + + if (!toRemove.isEmpty()) { + this.queuedContexts.removeAll(toRemove); + } + + // reschedule for the next earliest deadline + scheduleTimeoutAt(earliestDeadline); + + return nextEntry; + } + + // --------------------------------------------------------------------------- + QueuedContextStruct getNextQueuedContextEntry(QueuedContextStruct entry) { + List candidates = this.getAvailableResources(entry.getResources()); + if (candidates == null || candidates.isEmpty()) { + return null; + } + + entry.candidates = getResourcesNames(candidates); + LOGGER.fine("take this: " + entry); + return entry; + } + + // --------------------------------------------------------------------------- + /** + * Scans remote queue entries, removes invalid/timed-out ones, and returns the first entry + * (highest priority) whose resources are currently all available. + * Must be called under {@link #syncResources}. + */ + @CheckForNull + private RemoteQueueEntry getNextRemoteEntry() { + List list = getRemoteQueueEntries(); + List toRemove = new ArrayList<>(); + RemoteQueueEntry result = null; + + for (RemoteQueueEntry entry : list) { + if (!entry.isValid()) { + toRemove.add(entry); + continue; + } + if (entry.isTimedOut()) { + toRemove.add(entry); + LOGGER.info("Remote queue entry timed out waiting for resources: " + entry); + entry.onTimeout(); + continue; + } + if (result == null) { + // Resolve via the canonical path (same as local), with the exposure policy as filter. + List available = + new RemoteResolver(this).availableForRemote(entry.getStructs(), entry.getLockRequest()); + if (available != null) { + entry.setResolved(available); + result = entry; + } + } + } + + if (!toRemove.isEmpty()) { + list.removeAll(toRemove); + } + return result; + } + + // --------------------------------------------------------------------------- + /** Returns current queue */ + @Restricted(NoExternalUse.class) // used by jelly + public List getCurrentQueuedContext() { + synchronized (syncResources) { + return Collections.unmodifiableList(this.queuedContexts); + } + } + + // --------------------------------------------------------------------------- + /** + * Creates the resource if it does not exist and ephemeral resources are allowed. + * + * @param name the resource name + * @return true if resource was created, false if it already exists or ephemeral resources are + * disabled + */ + public boolean createResource(@CheckForNull String name) { + if (!allowEphemeralResources) { + LOGGER.fine("Ephemeral resources are disabled, not creating resource: " + name); + return false; + } + name = Util.fixEmptyAndTrim(name); + LockableResource resource = new LockableResource(name); + resource.setEphemeral(true); + + return this.addResource(resource, /*doSave*/ true); + } + + // --------------------------------------------------------------------------- + public boolean createResourceWithLabel(@CheckForNull String name, @CheckForNull String label) { + name = Util.fixEmptyAndTrim(name); + label = Util.fixEmptyAndTrim(label); + LockableResource resource = new LockableResource(name); + resource.setLabelsFromString(label); + + return this.addResource(resource, /*doSave*/ true); + } + + // --------------------------------------------------------------------------- + public boolean createResourceWithLabelAndProperties( + @CheckForNull String name, @CheckForNull String label, final Map properties) { + if (properties == null) { + return false; + } + + name = Util.fixEmptyAndTrim(name); + label = Util.fixEmptyAndTrim(label); + LockableResource resource = new LockableResource(name); + resource.setLabelsFromString(label); + resource.setProperties(properties.entrySet().stream() + .map(e -> { + LockableResourceProperty p = new LockableResourceProperty(); + p.setName(e.getKey()); + p.setValue(e.getValue()); + return p; + }) + .collect(Collectors.toList())); + + return this.addResource(resource, /*doSave*/ true); + } + + // --------------------------------------------------------------------------- + @Restricted(NoExternalUse.class) + public boolean addResource(@Nullable final LockableResource resource) { + return this.addResource(resource, /*doSave*/ false); + } + // --------------------------------------------------------------------------- + @Restricted(NoExternalUse.class) + public boolean addResource(@Nullable final LockableResource resource, final boolean doSave) { + + if (resource == null || resource.getName() == null || resource.getName().isEmpty()) { + LOGGER.warning("Internal failure: We will add wrong resource: '" + resource + "' " + getStack()); + return false; + } + synchronized (syncResources) { + if (this.resourceExist(resource.getName())) { + LOGGER.finest("We will add existing resource: " + resource + getStack()); + return false; + } + this.resources.add(resource); + LOGGER.fine("Resource added : " + resource); + + // Invalidate cache and process waiting pipeline jobs while still holding the lock + cachedCandidates.invalidateAll(); + while (proceedNextContext()) { + // process as many contexts as possible + } + + if (doSave) { + this.save(); + } + } + // Notify Jenkins queue for freestyle jobs (must be outside synchronized block) + scheduleQueueMaintenance(); + return true; + } + + // --------------------------------------------------------------------------- + /** + * Reserves an available resource for the userName indefinitely (until that person, or some + * explicit scripted action, decides to release the resource). + */ + public boolean reserve(List resources, String userName) { + return reserve(resources, userName, null); + } + + // --------------------------------------------------------------------------- + /** + * Reserves an available resource for the userName indefinitely (until that person, or some + * explicit scripted action, decides to release the resource). + * + * @param resources list of resources to reserve + * @param userName the user reserving the resources + * @param reason the reason for reserving (optional) + * @return true if all resources were successfully reserved, false if any was not free + */ + public boolean reserve(List resources, String userName, String reason) { + LOGGER.info("reserve() called user='" + userName + "' resources=" + getResourcesNames(resources) + " reason='" + + reason + "'"); + synchronized (syncResources) { + for (LockableResource r : resources) { + if (!r.isFree()) { + LOGGER.fine("reserve() failed because resource not free: " + r.getName()); + return false; + } + } + for (LockableResource r : resources) { + r.reserve(userName, reason); + } + save(); + } + LOGGER.info("reserve() succeeded user='" + userName + "' resources=" + getResourcesNames(resources)); + return true; + } + + // --------------------------------------------------------------------------- + /** + * Reserves a resource that may be or not be locked by some job (or reserved by some user) + * already, giving it away to the userName indefinitely (until that person, or some explicit + * scripted action, later decides to release the resource). + */ + public boolean steal(List resources, String userName) { + return steal(resources, userName, null); + } + + // --------------------------------------------------------------------------- + /** + * Reserves a resource that may be or not be locked by some job (or reserved by some user) + * already, giving it away to the userName indefinitely (until that person, or some explicit + * scripted action, later decides to release the resource). + * + * @param resources list of resources to steal + * @param userName the user stealing the resources + * @param reason the reason for stealing (optional) + * @return true if stolen successfully + */ + public boolean steal(List resources, String userName, String reason) { + synchronized (syncResources) { + for (LockableResource r : resources) { + r.setReservedBy(userName); + r.setStolen(); + } + unlockResources(resources); + Date date = new Date(); + for (LockableResource r : resources) { + r.setReservedTimestamp(date); + r.setLockReason(reason); + } + save(); + } + return true; + } + + // --------------------------------------------------------------------------- + /** + * Reserves a resource that may be or not be reserved by some person already, giving it away to + * the userName indefinitely (until that person, or some explicit scripted action, decides to + * release the resource). + */ + public void reassign(List resources, String userName) { + synchronized (syncResources) { + Date date = new Date(); + for (LockableResource r : resources) { + if (!r.isFree()) { + r.unReserve(); + } + r.setReservedBy(userName); + r.setReservedTimestamp(date); + } + save(); + } + } + + // --------------------------------------------------------------------------- + private void unreserveResources(@NonNull List resources) { + for (LockableResource l : resources) { + uncacheIfFreeing(l, false, true); + l.unReserve(); + } + save(); + } + + // --------------------------------------------------------------------------- + public void unreserve(List resources) { + // make sure there is a list of resources to unreserve + if (resources == null || resources.isEmpty()) { + return; + } + + synchronized (syncResources) { + LOGGER.fine("unreserve " + resources); + unreserveResources(resources); + + proceedNextContext(); + + save(); + } + scheduleQueueMaintenance(); + } + + // --------------------------------------------------------------------------- + @NonNull + @Override + public String getDisplayName() { + return Messages.LockableResourcesManager_displayName(); + } + + // --------------------------------------------------------------------------- + public void reset(List resources) { + synchronized (syncResources) { + for (LockableResource r : resources) { + uncacheIfFreeing(r, true, true); + r.reset(); + } + + while (proceedNextContext()) { + // process as many contexts as possible + } + + save(); + } + scheduleQueueMaintenance(); + } + + // --------------------------------------------------------------------------- + /** + * Make the lockable resource reusable and notify the queue(s), if any WARNING: Do not use this + * from inside the lock step closure which originally locked this resource, to avoid nasty + * surprises! Namely, this *might* let a second consumer use the resource quickly, but when the + * original closure ends and unlocks again that resource, a third consumer might then effectively + * hijack it from the second one. + */ + public void recycle(List resources) { + synchronized (syncResources) { + // Not calling reset() because that also un-queues the resource + // and we want to proclaim it is usable (if anyone is waiting) + this.unlockResources(resources); + this.unreserve(resources); + } + } + + // --------------------------------------------------------------------------- + /** Change the order (position) of the given item in the queue*/ + @Restricted(NoExternalUse.class) // used by jelly + public void changeQueueOrder(final String queueId, final int newPosition) throws IOException { + synchronized (syncResources) { + if (newPosition < 0 || newPosition >= this.queuedContexts.size()) { + throw new IOException( + Messages.error_queuePositionOutOfRange(newPosition + 1, this.queuedContexts.size())); + } + + int oldIndex = -1; + for (int i = 0; i < this.queuedContexts.size(); i++) { + QueuedContextStruct entry = this.queuedContexts.get(i); + if (entry.getId().equals(queueId)) { + oldIndex = i; + break; + } + } + + if (oldIndex < 0) { + // no more exists !? + throw new IOException(Messages.error_queueDoesNotExist(queueId)); + } + + Collections.swap(this.queuedContexts, oldIndex, newPosition); + } + } + + // --------------------------------------------------------------------------- + @Override + public boolean configure(StaplerRequest2 req, JSONObject json) { + synchronized (syncResources) { + try (BulkChange bc = new BulkChange(this)) { + req.bindJSON(this, json); + bc.commit(); + } catch (IOException exception) { + LOGGER.log(Level.WARNING, "Exception occurred while committing bulkchange operation.", exception); + return false; + } + } + // Consistency check after binding completes (form field order makes a + // setter-time check unreliable: forcedServerId binds before remotes). + if (!forcedServerId.isEmpty() && !getRemotesAsMap().containsKey(forcedServerId)) { + LOGGER.warning(Messages.warning_forcedServerIdNotConfigured(forcedServerId)); + } + return true; + } + + // --------------------------------------------------------------------------- + public List getAvailableResources(final List requiredResourcesList) { + return this.getAvailableResources(requiredResourcesList, null, null); + } + + // --------------------------------------------------------------------------- + /** Function removes all given resources */ + public void removeResources(List toBeRemoved) { + synchronized (syncResources) { + this.resources.removeAll(toBeRemoved); + } + scheduleQueueMaintenance(); + } + + // --------------------------------------------------------------------------- + /** + * Checks if there are enough resources available to satisfy the requirements specified within + * requiredResources and returns the necessary available resources. If not enough resources are + * available, returns null. + */ + public List getAvailableResources( + final List requiredResourcesList, + final @Nullable PrintStream logger, + final @Nullable ResourceSelectStrategy selectStrategy) { + return getAvailableResources(requiredResourcesList, logger, selectStrategy, r -> true); + } + + /** + * As {@link #getAvailableResources(List, PrintStream, ResourceSelectStrategy)} but restricts the + * candidate pool to resources accepted by {@code candidateFilter}. This is the seam the remote bridge + * uses to apply its {@code exposeLabel} visibility filter; local callers pass "accept all". The filter is + * a plain predicate (it knows nothing about exposeLabel), so the canonical resolver stays generic. It is + * applied to the candidate pool before count-based selection, so a label request with + * {@code amount<=0} ("all") means "all visible matching". + */ + public List getAvailableResources( + final List requiredResourcesList, + final @Nullable PrintStream logger, + final @Nullable ResourceSelectStrategy selectStrategy, + @NonNull final Predicate candidateFilter) { + + LOGGER.finest("getAvailableResources, " + requiredResourcesList); + List candidates = new ArrayList<>(); + for (LockableResourcesStruct requiredResources : requiredResourcesList) { + List available = new ArrayList<>(); + // filter by labels + if (requiredResources.label != null && !requiredResources.label.isBlank()) { + // get required amount first + int requiredAmount = 0; + if (requiredResources.requiredNumber != null) { + try { + requiredAmount = Integer.parseInt(requiredResources.requiredNumber); + } catch (NumberFormatException ignored) { + } + } + + available = this.getFreeResourcesWithLabel( + requiredResources.label, requiredAmount, selectStrategy, logger, candidates, candidateFilter); + } else if (requiredResources.required != null) { + // resource by name requested + + // this is a little hack. The 'requiredResources.required' is a copy, and we need to find + // all of them in LRM + // fromNames() also re-create the resource (ephemeral things) + available = fromNames( + getResourcesNames(requiredResources.required), /*create un-existent resources */ true); + + if (available.stream().anyMatch(r -> !candidateFilter.test(r)) || !this.areAllAvailable(available)) { + // not all available, or a requested resource is not visible to this caller + available = null; + } + } else { + LOGGER.warning("getAvailableResources, Not implemented: " + requiredResources); + } + + if (available == null || available.isEmpty()) { + LOGGER.finest("No available resources found " + requiredResourcesList); + return null; + } + + final boolean isPreReserved = !Collections.disjoint(candidates, available); + if (isPreReserved) { + // FIXME I think this is failure + // You use filter label1 and it lock resource1 and then in extra you will lock resource1 + // But when I allow this line, many tests will fails, and I am pretty sure it will throws + // exceptions on end-user pipelines + // So when we want to fix, it it might be braking-change + // Therefore keep it here as warning for now + printLogs("Extra filter tries to allocate pre-reserved resources.", logger, Level.WARNING); + available.removeAll(candidates); + } + + candidates.addAll(available); + } + + return candidates; + } + + // --------------------------------------------------------------------------- + private boolean areAllAvailable(List resources) { + for (LockableResource resource : resources) { + if (!resource.isFree()) { + return false; + } + } + return true; + } + + // --------------------------------------------------------------------------- + public static void printLogs(final String msg, final Level level, Logger L, final @Nullable PrintStream logger) { + L.log(level, msg); + + if (logger != null) { + if (level == Level.WARNING || level == Level.SEVERE) logger.println(level.getLocalizedName() + ": " + msg); + else logger.println(msg); + } + } + + // --------------------------------------------------------------------------- + private static void printLogs(final String msg, final @Nullable PrintStream logger, final Level level) { + printLogs(msg, level, LOGGER, logger); + } + + // --------------------------------------------------------------------------- + @CheckForNull + @Restricted(NoExternalUse.class) + private List getFreeResourcesWithLabel( + @NonNull String label, + long amount, + final @Nullable ResourceSelectStrategy selectStrategy, + final @Nullable PrintStream logger, + final List alreadySelected, + @NonNull final Predicate candidateFilter) { + List found = new ArrayList<>(); + + List candidates = _getResourcesWithLabel(label, alreadySelected); + candidates.addAll(this.getResourcesWithLabel(label)); + // Restrict to the visible candidate pool before count-based selection (e.g. remote exposure policy). + // Applied here so amount<=0 ("all") resolves to "all visible matching". + candidates.removeIf(r -> !candidateFilter.test(r)); + + if (amount <= 0) { + amount = candidates.size(); + } + + if (candidates.size() < amount) { + printLogs( + "Found " + + candidates.size() + + " possible resource(s). Waiting for correct amount: " + + amount + + "." + + "This may remain stuck, until you create enough resources", + logger, + Level.WARNING); + return null; // there are not enough resources + } + + if (selectStrategy != null && selectStrategy.equals(ResourceSelectStrategy.RANDOM)) { + Collections.shuffle(candidates); + } + + for (LockableResource r : candidates) { + // TODO: it shall be used isFree() here, but in that case we need to change the + // logic in parametrized builds and that is much more effort as I want to spend here now + if (!r.isReserved() && !r.isLocked()) { + found.add(r); + } + + if (amount > 0 && found.size() >= amount) { + return found; + } + } + + String msg = "Found " + found.size() + " available resource(s). Waiting for correct amount: " + amount + "."; + if (enabledBlockedCount != 0) { + msg += "\nBlocking causes: " + getCauses(candidates); + } + printLogs(msg, logger, Level.FINE); + + return null; + } + + // --------------------------------------------------------------------------- + // for debug purpose + private String getCauses(List resources) { + StringBuilder buf = new StringBuilder(); + int currentSize = 0; + for (LockableResource resource : resources) { + String cause = resource.getLockCauseDetail(); + if (cause == null) continue; // means it is free, not blocked + + currentSize++; + if (enabledBlockedCount > 0 && currentSize == enabledBlockedCount) { + buf.append("\n ..."); + break; + } + buf.append("\n ").append(cause); + + final String queueCause = getQueueCause(resource); + if (!queueCause.isEmpty()) { + buf.append(queueCause); + } + } + return buf.toString(); + } + + // --------------------------------------------------------------------------- + // for debug purpose + private String getQueueCause(final LockableResource resource) { + Map, Integer> usage = new HashMap<>(); + + for (QueuedContextStruct entry : this.queuedContexts) { + + Run build = entry.getBuild(); + if (build == null) { + LOGGER.warning("Why we don`t have the build? " + entry); + continue; + } + + int count = 0; + if (usage.containsKey(build)) { + count = usage.get(build); + } + + for (LockableResourcesStruct _struct : entry.getResources()) { + if (_struct.isResourceRequired(resource)) { + LOGGER.fine("found " + resource + " " + count); + count++; + break; + } + } + + usage.put(build, count); + } + + StringBuilder buf = new StringBuilder(); + int currentSize = 0; + for (Map.Entry, Integer> entry : usage.entrySet()) { + Run build = entry.getKey(); + int count = entry.getValue(); + + if (build != null && count > 0) { + currentSize++; + buf.append("\n Queued ") + .append(count) + .append(" time(s) by build ") + .append(build.getFullDisplayName()) + .append(" ") + .append(ModelHyperlinkNote.encodeTo(build)); + + if (currentSize >= enabledCausesCount) { + buf.append("\n ..."); + break; + } + } + } + return buf.toString(); + } + + /* + * Adds the given context and the required resources to the queue if + * this context is not yet queued. + */ + @Restricted(NoExternalUse.class) + public void queueContext( + StepContext context, + List requiredResources, + String resourceDescription, + String variableName, + boolean inversePrecedence, + int priority) { + queueContext( + context, + requiredResources, + resourceDescription, + variableName, + inversePrecedence, + priority, + null, + 0, + "MINUTES"); + } + + // --------------------------------------------------------------------------- + /* + * Adds the given context and the required resources to the queue if + * this context is not yet queued, with reason and timeout for resource allocation. + */ + @Restricted(NoExternalUse.class) + public void queueContext( + StepContext context, + List requiredResources, + String resourceDescription, + String variableName, + boolean inversePrecedence, + int priority, + String reason, + long timeoutForAllocateResource, + String timeoutUnit) { + synchronized (syncResources) { + for (QueuedContextStruct entry : this.queuedContexts) { + if (entry.getContext() == context) { + LOGGER.warning("queueContext, duplicated, " + requiredResources); return; + } + } + + int queueIndex = 0; + QueuedContextStruct newQueueItem = new QueuedContextStruct( + context, + requiredResources, + resourceDescription, + variableName, + priority, + reason, + timeoutForAllocateResource, + timeoutUnit); + + if (!inversePrecedence || priority != 0) { + queueIndex = this.queuedContexts.size() - 1; + for (; queueIndex >= 0; queueIndex--) { + QueuedContextStruct entry = this.queuedContexts.get(queueIndex); + final int rc = entry.compare(newQueueItem); + if (rc > 0) { + continue; + } + break; + } + queueIndex++; + } + + this.queuedContexts.add(queueIndex, newQueueItem); + printLogs( + requiredResources + " added into queue at position " + queueIndex, + newQueueItem.getLogger(), + Level.FINE); + + save(); + + // If this entry has a timeout and its deadline is earlier than the + // currently scheduled one, (re)schedule so it fires on time. + long deadline = newQueueItem.getTimeoutDeadlineMillis(); + if (deadline > 0 && (nextTimeoutDeadline == 0 || deadline < nextTimeoutDeadline)) { + scheduleTimeoutAt(deadline); + } + } + } + + // --------------------------------------------------------------------------- + public boolean unqueueContext(StepContext context) { + synchronized (syncResources) { + for (Iterator iter = this.queuedContexts.listIterator(); iter.hasNext(); ) { + if (iter.next().getContext() == context) { + iter.remove(); + save(); + return true; + } + } + } + return false; + } - try { - getConfigFile().write(this); - } catch (IOException e) { - LOGGER.log(Level.WARNING, "Failed to save " + getConfigFile(),e); + // --------------------------------------------------------------------------- + // Remote queue support + // --------------------------------------------------------------------------- + + /** Lazy init accessor - always call under {@link #syncResources}. */ + private List getRemoteQueueEntries() { + if (remoteQueueEntries == null) { + remoteQueueEntries = new ArrayList<>(); + } + return remoteQueueEntries; + } + + /** + * Returns a read-only snapshot of current remote queue entries. + * Used by the management UI to show remote API waiters alongside local queue entries. + */ + @Restricted(NoExternalUse.class) + public List getCurrentRemoteQueueEntries() { + synchronized (syncResources) { + return Collections.unmodifiableList(new ArrayList<>(getRemoteQueueEntries())); + } + } + + /** + * Adds a remote queue entry in priority-descending order (highest priority at front, + * matching local pipeline queue ordering). Must be called under {@link #syncResources}. + */ + @Restricted(NoExternalUse.class) + public void queueRemote(@NonNull RemoteQueueEntry entry) { + synchronized (syncResources) { + List list = getRemoteQueueEntries(); + int insertAt = list.size(); + for (int i = list.size() - 1; i >= 0; i--) { + if (list.get(i).getPriority() < entry.getPriority()) { + insertAt = i; + } else { + break; + } + } + list.add(insertAt, entry); + LOGGER.fine("Remote acquire queued: lockId=" + entry.getLockId() + " priority=" + entry.getPriority() + + " position=" + insertAt); + } + } + + /** + * Removes a remote queue entry by lockId (called when the client cancels a QUEUED request + * or when a release is received for a QUEUED record). Idempotent. + * Must be called under {@link #syncResources}. + */ + @Restricted(NoExternalUse.class) + public void unqueueRemote(@NonNull String lockId) { + synchronized (syncResources) { + List list = getRemoteQueueEntries(); + list.removeIf(e -> lockId.equals(e.getLockId())); + } + } + + /** + * Marks {@code resources} as remotely locked by {@code lockId}. + * Equivalent to {@link #lock(List, Run, String)} but uses {@code setRemoteLockedBy()} instead + * of {@code setBuild()} so that resources do not require an active Jenkins build. + * + * @return false if any resource is not free (defensive; caller should have pre-checked) + */ + @Restricted(NoExternalUse.class) + public boolean lockForRemote(@NonNull List resources, @NonNull String lockId) { + for (LockableResource r : resources) { + if (!r.isFree()) { + LOGGER.warning("lockForRemote: resource " + r.getName() + " is not free, lockId=" + lockId); + return false; + } + } + for (LockableResource r : resources) { + r.unqueue(); + r.setRemoteLockedBy(lockId); + } + save(); + return true; + } + + /** + * Frees remotely locked resources and wakes any waiters (local or remote). + * Equivalent to {@link #unlockResources(List, Run)} but for remote locks. + */ + @Restricted(NoExternalUse.class) + public void unlockRemoteResources(@NonNull List resourceNames, @NonNull String lockId) { + synchronized (syncResources) { + for (String name : resourceNames) { + LockableResource r = fromName(name); + if (r != null && lockId.equals(r.getRemoteLockedBy())) { + r.setRemoteLockedBy(null); } + } + while (proceedNextContext()) { + // drain all newly satisfiable waiters + } + save(); + } + scheduleQueueMaintenance(); + } + + // --------------------------------------------------------------------------- + public static LockableResourcesManager get() { + LockableResourcesManager mgr = GlobalConfiguration.all().get(LockableResourcesManager.class); + if (mgr == null) { + mgr = Jenkins.get().getDescriptorByType(LockableResourcesManager.class); + } + if (mgr == null) { + throw new IllegalStateException("LockableResourcesManager is not registered"); + } + return mgr; + } + + // --------------------------------------------------------------------------- + /** + * Trigger an immediate Queue re-evaluation so items waiting for lockable + * resources are dispatched as soon as resources become available, instead of + * waiting for the next 5-second timer tick. + *

+ * Must be called outside {@code synchronized(syncResources)} to avoid + * holding the plugin lock while Jenkins acquires the Queue lock. + */ + public static void scheduleQueueMaintenance() { + Jenkins j = Jenkins.getInstanceOrNull(); + if (j != null) { + j.getQueue().scheduleMaintenance(); + } + } + + // --------------------------------------------------------------------------- + /** + * Refresh the queue to allow waiting jobs to re-evaluate available resources. + *

+ * This method should be called after modifying labels on existing resources, + * as label changes do not automatically trigger queue re-evaluation. + *

+ * It performs the following actions: + *

    + *
  1. Invalidates the cached resource candidates
  2. + *
  3. Processes waiting pipeline job contexts
  4. + *
  5. Triggers Jenkins queue maintenance for freestyle jobs
  6. + *
+ */ + public void refreshQueue() { + // Invalidate cached candidates so waiting jobs re-evaluate with current labels + cachedCandidates.invalidateAll(); + + // Process waiting pipeline jobs (also handles timeouts) + synchronized (syncResources) { + while (proceedNextContext()) { + // process as many contexts as possible + } } - private static final Logger LOGGER = Logger.getLogger(LockableResourcesManager.class.getName()); + // Notify Jenkins queue for freestyle jobs + scheduleQueueMaintenance(); + } + + // --------------------------------------------------------------------------- + /** + * Checks for timed-out entries in the pipeline lock queue and fails them. + * Called by {@link org.jenkins.plugins.lockableresources.queue.LockWaitTimeoutPeriodicWork} + * as a safety net. + */ + @Restricted(NoExternalUse.class) + public void checkTimeouts() { + synchronized (syncResources) { + // proceedNextContext → getNextQueuedContext handles timeouts + rescheduling + while (proceedNextContext()) { + // process as many contexts as possible + } + } + } + + // --------------------------------------------------------------------------- + /** + * Schedules (or reschedules) the single timeout task to fire at the given + * deadline. If {@code deadline} is {@link Long#MAX_VALUE} the current task + * is cancelled and nothing new is scheduled. + * Must be called while holding {@link #syncResources}. + */ + private void scheduleTimeoutAt(long deadline) { + // Cancel the current task — we will either replace it or clear it + if (nextTimeoutTask != null) { + nextTimeoutTask.cancel(false); + nextTimeoutTask = null; + nextTimeoutDeadline = 0; + } + if (deadline == Long.MAX_VALUE || deadline <= 0) { + return; + } + + nextTimeoutDeadline = deadline; + // Small buffer so the deadline has definitely passed when we check + long delayMs = Math.max(0, deadline - System.currentTimeMillis()) + 500L; + LOGGER.fine("Scheduling timeout check in " + delayMs + "ms"); + nextTimeoutTask = jenkins.util.Timer.get() + .schedule( + () -> { + LOGGER.fine("Scheduled timeout check fired"); + synchronized (syncResources) { + nextTimeoutDeadline = 0; + nextTimeoutTask = null; + while (proceedNextContext()) { + // process as many contexts as possible + } + } + }, + delayMs, + java.util.concurrent.TimeUnit.MILLISECONDS); + } + + // --------------------------------------------------------------------------- + private AtomicBoolean getSavePending() { + AtomicBoolean sp = savePending; + if (sp == null) { + synchronized (this) { + sp = savePending; + if (sp == null) { + savePending = sp = new AtomicBoolean(false); + } + } + } + return sp; + } + + private ScheduledExecutorService getSaveExecutor() { + ScheduledExecutorService se = saveExecutor; + if (se == null) { + synchronized (this) { + se = saveExecutor; + if (se == null) { + saveExecutor = se = Executors.newSingleThreadScheduledExecutor(r -> { + Thread t = new Thread(r, "lockable-resources-async-save"); + t.setDaemon(true); + return t; + }); + } + } + } + return se; + } + + @Override + public void save() { + if (enableSave == -1) { + // read system property and cache it. + enableSave = SystemProperties.getBoolean(Constants.SYSTEM_PROPERTY_DISABLE_SAVE) ? 0 : 1; + } + + if (enableSave == 0) return; // saving is disabled + + if (BulkChange.contains(this)) return; + + if (asyncSaveEnabled && saveCoalesceMs > 0) { + if (getSavePending().compareAndSet(false, true)) { + getSaveExecutor().schedule(this::doSave, saveCoalesceMs, TimeUnit.MILLISECONDS); + } + } else { + doSave(); + } + } + + private void doSave() { + getSavePending().set(false); + synchronized (syncResources) { + try { + getConfigFile().write(this); + } catch (IOException e) { + LOGGER.log(Level.WARNING, "Failed to save " + getConfigFile(), e); + } + } + } + + // --------------------------------------------------------------------------- + /** + * Flush any pending async save during Jenkins shutdown so that lock state + * is never lost on an orderly restart. + */ + @Terminator + public static void flushPendingSave() { + LockableResourcesManager lrm = GlobalConfiguration.all().get(LockableResourcesManager.class); + if (lrm == null) { + return; + } + ScheduledExecutorService se = lrm.saveExecutor; + if (se != null) { + se.shutdownNow(); + } + if (lrm.savePending != null && lrm.savePending.compareAndSet(true, false)) { + lrm.doSave(); + LOGGER.fine("Flushed pending async save during shutdown"); + } + } + + // --------------------------------------------------------------------------- + /** For testing purpose. */ + @Restricted(NoExternalUse.class) + public LockableResource getFirst() { + return this.getResources().get(0); + } } diff --git a/src/main/java/org/jenkins/plugins/lockableresources/RemoteConnection.java b/src/main/java/org/jenkins/plugins/lockableresources/RemoteConnection.java new file mode 100644 index 000000000..613195705 --- /dev/null +++ b/src/main/java/org/jenkins/plugins/lockableresources/RemoteConnection.java @@ -0,0 +1,139 @@ +/* + * The MIT License + * + * See the "LICENSE.txt" file for full copyright and license information. + */ +package org.jenkins.plugins.lockableresources; + +import com.cloudbees.plugins.credentials.CredentialsMatchers; +import com.cloudbees.plugins.credentials.common.StandardListBoxModel; +import com.cloudbees.plugins.credentials.common.StandardUsernamePasswordCredentials; +import com.cloudbees.plugins.credentials.domains.URIRequirementBuilder; +import hudson.Extension; +import hudson.Util; +import hudson.model.AbstractDescribableImpl; +import hudson.model.Descriptor; +import hudson.security.ACL; +import hudson.util.FormValidation; +import hudson.util.ListBoxModel; +import java.util.Locale; +import java.util.Objects; +import jenkins.model.Jenkins; +import org.kohsuke.stapler.DataBoundConstructor; +import org.kohsuke.stapler.QueryParameter; +import org.kohsuke.stapler.verb.POST; + +/** + * Remote Jenkins connection settings. + */ +public class RemoteConnection extends AbstractDescribableImpl { + + private final String serverId; + private final String url; + private final String credentialsId; + + @DataBoundConstructor + public RemoteConnection(String serverId, String url, String credentialsId) { + this.serverId = serverId; + this.url = url; + this.credentialsId = credentialsId; + } + + public String getServerId() { + return serverId; + } + + public String getUrl() { + return url; + } + + public String getCredentialsId() { + return credentialsId; + } + + public void validate() { + if (serverId == null || serverId.isEmpty()) { + throw new IllegalArgumentException("serverId must not be null or empty"); + } + if (url == null || url.isEmpty()) { + throw new IllegalArgumentException("url must not be null or empty"); + } + if (!isHttpUrl(url)) { + // The remote base URL is used by the HTTP transport (RemoteApiClient); reject + // non-http(s) schemes (e.g. file:, ftp:) up front instead of failing opaquely at lock() time. + throw new IllegalArgumentException("url must be an http:// or https:// URL: " + url); + } + } + + /** A remote base URL must be an absolute http(s) URL (network-bridge transport requirement). */ + private static boolean isHttpUrl(String value) { + if (value == null) { + return false; + } + String v = value.trim().toLowerCase(Locale.ENGLISH); + return v.startsWith("http://") || v.startsWith("https://"); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + RemoteConnection that = (RemoteConnection) o; + return Objects.equals(serverId, that.serverId) + && Objects.equals(url, that.url) + && Objects.equals(credentialsId, that.credentialsId); + } + + @Override + public int hashCode() { + return Objects.hash(serverId, url, credentialsId); + } + + @Override + public String toString() { + return "RemoteConnection{" + "serverId='" + serverId + '\'' + ", url='" + url + '\'' + "}"; + } + + @Extension + public static class DescriptorImpl extends Descriptor { + + /** + * Credentials dropdown for remote API auth (username + API token in password field). + */ + @POST + public ListBoxModel doFillCredentialsIdItems(@QueryParameter String credentialsId, @QueryParameter String url) { + if (!Jenkins.get().hasPermission(Jenkins.ADMINISTER)) { + return new StandardListBoxModel().includeCurrentValue(credentialsId); + } + + return new StandardListBoxModel() + .includeEmptyValue() + .includeMatchingAs( + ACL.SYSTEM2, + Jenkins.get(), + StandardUsernamePasswordCredentials.class, + URIRequirementBuilder.fromUri(Util.fixEmptyAndTrim(url)) + .build(), + CredentialsMatchers.always()) + .includeCurrentValue(credentialsId); + } + + /** Live validation that the remote base URL is an http(s) URL. */ + @POST + public FormValidation doCheckUrl(@QueryParameter String value) { + Jenkins.get().checkPermission(Jenkins.ADMINISTER); + String trimmed = Util.fixEmptyAndTrim(value); + if (trimmed == null) { + return FormValidation.error("URL must not be empty"); + } + if (!isHttpUrl(trimmed)) { + return FormValidation.error("URL must start with http:// or https://"); + } + return FormValidation.ok(); + } + } +} diff --git a/src/main/java/org/jenkins/plugins/lockableresources/RequiredResourcesProperty.java b/src/main/java/org/jenkins/plugins/lockableresources/RequiredResourcesProperty.java index 2da486021..574ad5423 100644 --- a/src/main/java/org/jenkins/plugins/lockableresources/RequiredResourcesProperty.java +++ b/src/main/java/org/jenkins/plugins/lockableresources/RequiredResourcesProperty.java @@ -1,276 +1,377 @@ -/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * - * Copyright (c) 2013, 6WIND S.A. All rights reserved. * - * * - * This file is part of the Jenkins Lockable Resources Plugin and is * - * published under the MIT license. * - * * - * See the "LICENSE.txt" file for more information. * - * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ +/* + * The MIT License + * + * See the "LICENSE.txt" file for full copyright and license information. + */ package org.jenkins.plugins.lockableresources; +import edu.umd.cs.findbugs.annotations.CheckForNull; +import edu.umd.cs.findbugs.annotations.NonNull; import hudson.Extension; import hudson.Util; import hudson.model.AbstractProject; import hudson.model.AutoCompletionCandidates; +import hudson.model.Descriptor; +import hudson.model.Item; +import hudson.model.Job; import hudson.model.JobProperty; import hudson.model.JobPropertyDescriptor; -import hudson.model.Job; import hudson.util.FormValidation; - +import hudson.util.ListBoxModel; import java.util.ArrayList; import java.util.List; - +import java.util.regex.Pattern; +import jenkins.model.Jenkins; import net.sf.json.JSONObject; - import org.jenkinsci.plugins.scriptsecurity.sandbox.groovy.SecureGroovyScript; import org.jenkinsci.plugins.scriptsecurity.scripts.ApprovalContext; +import org.kohsuke.stapler.AncestorInPath; import org.kohsuke.stapler.DataBoundConstructor; +import org.kohsuke.stapler.DataBoundSetter; import org.kohsuke.stapler.QueryParameter; -import org.kohsuke.stapler.StaplerRequest; - -import javax.annotation.CheckForNull; +import org.kohsuke.stapler.StaplerRequest2; +import org.kohsuke.stapler.interceptor.RequirePOST; public class RequiredResourcesProperty extends JobProperty> { - private final String resourceNames; - private final String resourceNamesVar; - private final String resourceNumber; - private final String labelName; - private final @CheckForNull SecureGroovyScript resourceMatchScript; - - @DataBoundConstructor - public RequiredResourcesProperty(String resourceNames, - String resourceNamesVar, String resourceNumber, - String labelName, @CheckForNull SecureGroovyScript resourceMatchScript) { - super(); - - if (resourceNames == null || resourceNames.trim().isEmpty()) { - this.resourceNames = null; - } else { - this.resourceNames = resourceNames.trim(); - } - if (resourceNamesVar == null || resourceNamesVar.trim().isEmpty()) { - this.resourceNamesVar = null; - } else { - this.resourceNamesVar = resourceNamesVar.trim(); - } - if (resourceNumber == null || resourceNumber.trim().isEmpty()) { - this.resourceNumber = null; - } else { - this.resourceNumber = resourceNumber.trim(); - } - String labelNamePreparation = (labelName == null || labelName.trim().isEmpty()) ? null : labelName.trim(); - if (resourceMatchScript != null) { - this.resourceMatchScript = resourceMatchScript.configuringWithKeyItem(); - this.labelName = labelNamePreparation; - } else if (labelName != null && labelName.startsWith(LockableResource.GROOVY_LABEL_MARKER)) { - this.resourceMatchScript = new SecureGroovyScript(labelName.substring(LockableResource.GROOVY_LABEL_MARKER.length()), - false, null).configuring(ApprovalContext.create()); - this.labelName = null; - } else { - this.resourceMatchScript = null; - this.labelName = labelNamePreparation; - } - } - - @Deprecated - public RequiredResourcesProperty(String resourceNames, - String resourceNamesVar, String resourceNumber, - String labelName) { - this(resourceNames, resourceNamesVar, resourceNumber, labelName, null); - } - - private Object readResolve() { - // SECURITY-368 migration logic - if (resourceMatchScript == null && labelName != null && labelName.startsWith(LockableResource.GROOVY_LABEL_MARKER)) { - return new RequiredResourcesProperty(resourceNames, resourceNamesVar, resourceNumber, null, - new SecureGroovyScript(labelName.substring(LockableResource.GROOVY_LABEL_MARKER.length()), false, null) - .configuring(ApprovalContext.create())); - } - - return this; - } - - public String[] getResources() { - String names = Util.fixEmptyAndTrim(resourceNames); - if (names != null) - return names.split("\\s+"); - else - return new String[0]; - } - - public String getResourceNames() { - return resourceNames; - } - - public String getResourceNamesVar() { - return resourceNamesVar; - } - - public String getResourceNumber() { - return resourceNumber; - } - - public String getLabelName() { - return labelName; - } - - /** - * Gets a system Groovy script to be executed in order to determine if the {@link LockableResource} matches the condition. - * @return System Groovy Script if defined - * @since TODO - * @see LockableResource#scriptMatches(org.jenkinsci.plugins.scriptsecurity.sandbox.groovy.SecureGroovyScript, java.util.Map) - */ - @CheckForNull - public SecureGroovyScript getResourceMatchScript() { - return resourceMatchScript; - } - - @Extension - public static class DescriptorImpl extends JobPropertyDescriptor { - - @Override - public String getDisplayName() { - return "Required Lockable Resources"; - } - - @Override - public boolean isApplicable(Class jobType) { - return AbstractProject.class.isAssignableFrom(jobType); - } - - @Override - public RequiredResourcesProperty newInstance(StaplerRequest req, JSONObject formData) throws FormException { - if (formData.containsKey("required-lockable-resources")) { - return (RequiredResourcesProperty) super.newInstance(req, formData.getJSONObject("required-lockable-resources")); - } - return null; - } - - public FormValidation doCheckResourceNames(@QueryParameter String value, - @QueryParameter String labelName, - @QueryParameter boolean script) { - String labelVal = Util.fixEmptyAndTrim(labelName); - String names = Util.fixEmptyAndTrim(value); - - if (names == null) { - return FormValidation.ok(); - } else if (labelVal != null || script) { - return FormValidation.error( - "Only label, groovy expression, or resources can be defined, not more than one."); - } else { - List wrongNames = new ArrayList(); - for (String name : names.split("\\s+")) { - boolean found = false; - for (LockableResource r : LockableResourcesManager.get() - .getResources()) { - if (r.getName().equals(name)) { - found = true; - break; - } - } - if (!found) - wrongNames.add(name); - } - if (wrongNames.isEmpty()) { - return FormValidation.ok(); - } else { - return FormValidation - .error("The following resources do not exist: " - + wrongNames); - } - } - } - - public FormValidation doCheckLabelName( - @QueryParameter String value, - @QueryParameter String resourceNames, - @QueryParameter boolean script) { - String label = Util.fixEmptyAndTrim(value); - String names = Util.fixEmptyAndTrim(resourceNames); - - if (label == null) { - return FormValidation.ok(); - } else if (names != null || script) { - return FormValidation.error( - "Only label, groovy expression, or resources can be defined, not more than one."); - } else { - if (LockableResourcesManager.get().isValidLabel(label)) { - return FormValidation.ok(); - } else { - return FormValidation.error( - "The label does not exist: " + label); - } - } - } - - public FormValidation doCheckResourceNumber(@QueryParameter String value, - @QueryParameter String resourceNames, + private final String resourceNames; + private final String resourceNamesVar; + private final String resourceNumber; + private final String labelName; + private final @CheckForNull SecureGroovyScript resourceMatchScript; + + /** + * Timeout for waiting to acquire the resource, in the specified {@link #lockTimeoutUnit}. + * 0 means no timeout (wait indefinitely). + */ + private long lockTimeout = 0; + + /** + * Time unit for {@link #lockTimeout}. Defaults to MINUTES. + */ + private String lockTimeoutUnit = "MINUTES"; + + @DataBoundConstructor + public RequiredResourcesProperty( + String resourceNames, + String resourceNamesVar, + String resourceNumber, + String labelName, + @CheckForNull SecureGroovyScript resourceMatchScript) + throws Descriptor.FormException { + super(); + + if (resourceNames == null || resourceNames.trim().isEmpty()) { + this.resourceNames = null; + } else { + this.resourceNames = resourceNames.trim(); + } + if (resourceNamesVar == null || resourceNamesVar.trim().isEmpty()) { + this.resourceNamesVar = null; + } else { + this.resourceNamesVar = resourceNamesVar.trim(); + } + if (resourceNumber == null || resourceNumber.trim().isEmpty()) { + this.resourceNumber = null; + } else { + this.resourceNumber = resourceNumber.trim(); + } + String labelNamePreparation = (labelName == null || labelName.trim().isEmpty()) ? null : labelName.trim(); + if (resourceMatchScript != null) { + this.resourceMatchScript = resourceMatchScript.configuringWithKeyItem(); + this.labelName = labelNamePreparation; + } else if (labelName != null && labelName.startsWith(LockableResource.GROOVY_LABEL_MARKER)) { + this.resourceMatchScript = new SecureGroovyScript( + labelName.substring(LockableResource.GROOVY_LABEL_MARKER.length()), false, null) + .configuring(ApprovalContext.create()); + this.labelName = null; + } else { + this.resourceMatchScript = null; + this.labelName = labelNamePreparation; + } + } + + /** + * @deprecated groovy script was added (since 2.0) + */ + @Deprecated + @ExcludeFromJacocoGeneratedReport + public RequiredResourcesProperty( + String resourceNames, String resourceNamesVar, String resourceNumber, String labelName) + throws Descriptor.FormException { + this(resourceNames, resourceNamesVar, resourceNumber, labelName, null); + } + + private Object readResolve() throws Descriptor.FormException { + // SECURITY-368 migration logic + if (resourceMatchScript == null + && labelName != null + && labelName.startsWith(LockableResource.GROOVY_LABEL_MARKER)) { + return new RequiredResourcesProperty( + resourceNames, + resourceNamesVar, + resourceNumber, + null, + new SecureGroovyScript( + labelName.substring(LockableResource.GROOVY_LABEL_MARKER.length()), false, null) + .configuring(ApprovalContext.create())); + } + + return this; + } + + public String[] getResources() { + String names = Util.fixEmptyAndTrim(resourceNames); + if (names != null) return names.split("\\s+"); + else return new String[0]; + } + + public String getResourceNames() { + return resourceNames; + } + + public String getResourceNamesVar() { + return resourceNamesVar; + } + + public String getResourceNumber() { + return resourceNumber; + } + + public String getLabelName() { + return labelName; + } + + /** + * Gets a system Groovy script to be executed in order to determine if the {@link + * LockableResource} matches the condition. + * + * @return System Groovy Script if defined + * @since 2.0 + * @see + * LockableResource#scriptMatches(org.jenkinsci.plugins.scriptsecurity.sandbox.groovy.SecureGroovyScript, + * java.util.Map) + */ + @CheckForNull + public SecureGroovyScript getResourceMatchScript() { + return resourceMatchScript; + } + + public long getLockTimeout() { + return lockTimeout; + } + + @DataBoundSetter + public void setLockTimeout(long lockTimeout) { + this.lockTimeout = lockTimeout; + } + + public String getLockTimeoutUnit() { + return lockTimeoutUnit; + } + + @DataBoundSetter + public void setLockTimeoutUnit(String lockTimeoutUnit) { + this.lockTimeoutUnit = lockTimeoutUnit; + } + + @Extension + public static class DescriptorImpl extends JobPropertyDescriptor { + + /** Detects {@code ${...}} variable references that are resolved at build time. */ + private static final Pattern VARIABLE_PATTERN = Pattern.compile("\\$\\{[^}]+}"); + + @NonNull + @Override + public String getDisplayName() { + return Messages.RequiredResourcesProperty_displayName(); + } + + @Override + public boolean isApplicable(Class jobType) { + return AbstractProject.class.isAssignableFrom(jobType); + } + + @Override + public RequiredResourcesProperty newInstance(StaplerRequest2 req, JSONObject formData) throws FormException { + if (formData.containsKey("required-lockable-resources")) { + return (RequiredResourcesProperty) + super.newInstance(req, formData.getJSONObject("required-lockable-resources")); + } + return null; + } + + @RequirePOST + public FormValidation doCheckResourceNames( + @QueryParameter String value, @QueryParameter String labelName, - @QueryParameter String resourceMatchScript) - { + @QueryParameter boolean script, + @AncestorInPath Item item) { + // check permission, security first + checkPermission(item); + + String labelVal = Util.fixEmptyAndTrim(labelName); + String names = Util.fixEmptyAndTrim(value); + + if (names == null) { + return FormValidation.ok(); + } else if (labelVal != null || script) { + return FormValidation.error(Messages.error_labelAndNameOrGroovySpecified()); + } else { + List wrongNames = new ArrayList<>(); + for (String name : names.split("\\s+")) { + // Skip validation for names containing build-parameter references + if (VARIABLE_PATTERN.matcher(name).find()) { + continue; + } + boolean found = LockableResourcesManager.get().resourceExist(name); + if (!found) wrongNames.add(name); + } + if (wrongNames.isEmpty()) { + if (VARIABLE_PATTERN.matcher(names).find()) { + return FormValidation.warning(Messages.warning_resourceNameContainsVariable()); + } + return FormValidation.ok(); + } else { + return FormValidation.error(Messages.error_resourceDoesNotExist(wrongNames)); + } + } + } + + @RequirePOST + public FormValidation doCheckLabelName( + @QueryParameter String value, + @QueryParameter String resourceNames, + @QueryParameter boolean script, + @AncestorInPath Item item) { + // check permission, security first + checkPermission(item); + + String label = Util.fixEmptyAndTrim(value); + String names = Util.fixEmptyAndTrim(resourceNames); + + if (label == null) { + return FormValidation.ok(); + } else if (names != null || script) { + return FormValidation.error(Messages.error_labelAndNameOrGroovySpecified()); + } else { + // Skip label validation when it contains build-parameter references + if (VARIABLE_PATTERN.matcher(label).find()) { + return FormValidation.warning(Messages.warning_labelContainsVariable()); + } + if (LockableResourcesManager.get().isValidLabel(label)) { + return FormValidation.ok(); + } else { + return FormValidation.error(Messages.error_labelDoesNotExist(label)); + } + } + } - String number = Util.fixEmptyAndTrim(value); - String names = Util.fixEmptyAndTrim(resourceNames); - String label = Util.fixEmptyAndTrim(labelName); + @RequirePOST + public FormValidation doCheckResourceNumber( + @QueryParameter String value, + @QueryParameter String resourceNames, + @QueryParameter String labelName, + @QueryParameter String resourceMatchScript, + @AncestorInPath Item item) { + // check permission, security first + checkPermission(item); + + String number = Util.fixEmptyAndTrim(value); + String names = Util.fixEmptyAndTrim(resourceNames); + String label = Util.fixEmptyAndTrim(labelName); String script = Util.fixEmptyAndTrim(resourceMatchScript); - if (number == null || number.equals("") || number.trim().equals("0")) { - return FormValidation.ok(); - } - - int numAsInt; - try { - numAsInt = Integer.parseInt(number); - } catch(NumberFormatException e) { - return FormValidation.error( - "Could not parse the given value as integer."); - } - int numResources = 0; - if (names != null) { - numResources = names.split("\\s+").length; + if (number == null || number.isEmpty() || number.trim().equals("0")) { + return FormValidation.ok(); + } + + // Skip numeric validation when number contains build-parameter references + if (VARIABLE_PATTERN.matcher(number).find()) { + return FormValidation.warning(Messages.warning_resourceNumberContainsVariable()); + } + + int numAsInt; + try { + numAsInt = Integer.parseInt(number); + } catch (NumberFormatException e) { + return FormValidation.error(Messages.error_couldNotParseToint()); + } + int numResources = 0; + if (names != null) { + numResources = names.split("\\s+").length; } else if (label != null || script != null) { - numResources = Integer.MAX_VALUE; + numResources = Integer.MAX_VALUE; } - if (numResources < numAsInt) { - return FormValidation.error(String.format( - "Given amount %d is greater than amount of resources: %d.", - numAsInt, - numResources)); - } - return FormValidation.ok(); - } - - public AutoCompletionCandidates doAutoCompleteLabelName( - @QueryParameter String value) { - AutoCompletionCandidates c = new AutoCompletionCandidates(); - - value = Util.fixEmptyAndTrim(value); - - for (String l : LockableResourcesManager.get().getAllLabels()) - if (value != null && l.startsWith(value)) - c.add(l); - - return c; - } - - public static AutoCompletionCandidates doAutoCompleteResourceNames( - @QueryParameter String value) { - AutoCompletionCandidates c = new AutoCompletionCandidates(); - - value = Util.fixEmptyAndTrim(value); - - if (value != null) { - for (LockableResource r : LockableResourcesManager.get() - .getResources()) { - if (r.getName().startsWith(value)) - c.add(r.getName()); - } - } - - return c; - } - } -} + if (numResources < numAsInt) { + return FormValidation.error(String.format( + Messages.error_givenAmountIsGreaterThatResourcesAmount(), numAsInt, numResources)); + } + return FormValidation.ok(); + } + + @RequirePOST + public AutoCompletionCandidates doAutoCompleteLabelName( + @QueryParameter String value, @AncestorInPath Item item) { + // check permission, security first + checkPermission(item); + + AutoCompletionCandidates c = new AutoCompletionCandidates(); + + value = Util.fixEmptyAndTrim(value); + + if (value == null) { + return c; + } + for (String l : LockableResourcesManager.get().getAllLabels()) { + if (l.startsWith(value)) { + c.add(l); + } + } + + return c; + } + + @RequirePOST + public static AutoCompletionCandidates doAutoCompleteResourceNames( + @QueryParameter String value, @AncestorInPath Item item) { + // check permission, security first + checkPermission(item); + AutoCompletionCandidates c = new AutoCompletionCandidates(); + + value = Util.fixEmptyAndTrim(value); + + if (value == null) { + return c; + } + List allNames = LockableResourcesManager.get().getAllResourcesNames(); + for (String name : allNames) { + if (name.startsWith(value)) { + c.add(name); + } + } + + return c; + } + + private static void checkPermission(Item item) { + if (item != null) { + item.checkPermission(Item.CONFIGURE); + } else { + Jenkins.get().checkPermission(Jenkins.ADMINISTER); + } + } + + @RequirePOST + public ListBoxModel doFillLockTimeoutUnitItems(@AncestorInPath Item item) { + checkPermission(item); + ListBoxModel items = new ListBoxModel(); + items.add("Seconds", "SECONDS"); + items.add("Minutes", "MINUTES"); + items.add("Hours", "HOURS"); + return items; + } + } +} diff --git a/src/main/java/org/jenkins/plugins/lockableresources/ResourceSelectStrategy.java b/src/main/java/org/jenkins/plugins/lockableresources/ResourceSelectStrategy.java new file mode 100644 index 000000000..0b1152731 --- /dev/null +++ b/src/main/java/org/jenkins/plugins/lockableresources/ResourceSelectStrategy.java @@ -0,0 +1,6 @@ +package org.jenkins.plugins.lockableresources; + +public enum ResourceSelectStrategy { + SEQUENTIAL, + RANDOM +} diff --git a/src/main/java/org/jenkins/plugins/lockableresources/UpdateLockStep.java b/src/main/java/org/jenkins/plugins/lockableresources/UpdateLockStep.java new file mode 100644 index 000000000..eeb5bb40c --- /dev/null +++ b/src/main/java/org/jenkins/plugins/lockableresources/UpdateLockStep.java @@ -0,0 +1,312 @@ +/* + * The MIT License + * + * See the "LICENSE.txt" file for full copyright and license information. + */ +package org.jenkins.plugins.lockableresources; + +import edu.umd.cs.findbugs.annotations.CheckForNull; +import edu.umd.cs.findbugs.annotations.NonNull; +import hudson.Extension; +import hudson.Util; +import hudson.model.AutoCompletionCandidates; +import hudson.model.Item; +import hudson.model.TaskListener; +import hudson.util.FormValidation; +import java.io.Serializable; +import java.util.Collections; +import java.util.Set; +import java.util.logging.Logger; +import jenkins.model.Jenkins; +import org.jenkinsci.plugins.workflow.steps.Step; +import org.jenkinsci.plugins.workflow.steps.StepContext; +import org.jenkinsci.plugins.workflow.steps.StepDescriptor; +import org.jenkinsci.plugins.workflow.steps.StepExecution; +import org.kohsuke.stapler.AncestorInPath; +import org.kohsuke.stapler.DataBoundConstructor; +import org.kohsuke.stapler.DataBoundSetter; +import org.kohsuke.stapler.QueryParameter; +import org.kohsuke.stapler.interceptor.RequirePOST; + +/** + * Pipeline step to update the definition of a lockable resource. + * + *

This step allows pipelines to: + *

    + *
  • Create new resources
  • + *
  • Delete existing resources
  • + *
  • Add, remove, or set labels on resources
  • + *
  • Set notes on resources
  • + *
+ */ +public class UpdateLockStep extends Step implements Serializable { + + private static final Logger LOG = Logger.getLogger(UpdateLockStep.class.getName()); + private static final long serialVersionUID = -7955849755535282258L; + + @CheckForNull + private String resource = null; + + @CheckForNull + private String addLabels = null; + + @CheckForNull + private String setLabels = null; + + @CheckForNull + private String removeLabels = null; + + @CheckForNull + private String setNote = null; + + private boolean createResource = false; + private boolean deleteResource = false; + + @DataBoundConstructor + public UpdateLockStep() { + // default constructor + } + + @CheckForNull + public String getResource() { + return resource; + } + + @DataBoundSetter + public void setResource(String resource) { + if (resource != null && !resource.trim().isEmpty()) { + if (!resource.equals(resource.trim())) { + LOG.warning("The provided 'resource' should not start or end with spaces."); + } + this.resource = resource.trim(); + } + } + + @CheckForNull + public String getAddLabels() { + return addLabels; + } + + @DataBoundSetter + public void setAddLabels(String addLabels) { + addLabels = Util.fixEmptyAndTrim(addLabels); + if (addLabels != null) { + this.addLabels = addLabels; + } + } + + @CheckForNull + public String getSetLabels() { + return setLabels; + } + + @DataBoundSetter + public void setSetLabels(String setLabels) { + setLabels = Util.fixEmptyAndTrim(setLabels); + if (setLabels != null) { + this.setLabels = setLabels; + } + } + + @CheckForNull + public String getRemoveLabels() { + return removeLabels; + } + + @DataBoundSetter + public void setRemoveLabels(String removeLabels) { + removeLabels = Util.fixEmptyAndTrim(removeLabels); + if (removeLabels != null) { + this.removeLabels = removeLabels; + } + } + + @CheckForNull + public String getSetNote() { + return setNote; + } + + @DataBoundSetter + public void setSetNote(String setNote) { + setNote = Util.fixEmptyAndTrim(setNote); + if (setNote != null) { + this.setNote = setNote; + } + } + + public boolean isCreateResource() { + return createResource; + } + + @DataBoundSetter + public void setCreateResource(boolean createResource) { + this.createResource = createResource; + } + + public boolean isDeleteResource() { + return deleteResource; + } + + @DataBoundSetter + public void setDeleteResource(boolean deleteResource) { + this.deleteResource = deleteResource; + } + + /** + * Validates the step configuration. + * + * @throws IllegalArgumentException if the configuration is invalid + */ + public void validate() { + if (Util.fixEmptyAndTrim(resource) == null) { + throw new IllegalArgumentException(Messages.UpdateLockStep_error_resourceRequired()); + } + if (deleteResource && createResource) { + throw new IllegalArgumentException(Messages.UpdateLockStep_error_deleteAndCreateConflict()); + } + if (deleteResource && (addLabels != null || setLabels != null || removeLabels != null || setNote != null)) { + throw new IllegalArgumentException(Messages.UpdateLockStep_error_deleteWithOtherOptions()); + } + if (setLabels != null && (addLabels != null || removeLabels != null)) { + throw new IllegalArgumentException(Messages.UpdateLockStep_error_setLabelsConflict()); + } + } + + @Override + public StepExecution start(StepContext context) { + return new UpdateLockStepExecution(this, context); + } + + @Override + public String toString() { + StringBuilder sb = + new StringBuilder("UpdateLockStep{resource='").append(resource).append("'"); + if (createResource) sb.append(", createResource=true"); + if (deleteResource) sb.append(", deleteResource=true"); + if (setLabels != null) sb.append(", setLabels='").append(setLabels).append("'"); + if (addLabels != null) sb.append(", addLabels='").append(addLabels).append("'"); + if (removeLabels != null) + sb.append(", removeLabels='").append(removeLabels).append("'"); + if (setNote != null) sb.append(", setNote='").append(setNote).append("'"); + sb.append("}"); + return sb.toString(); + } + + @Extension + public static final class DescriptorImpl extends StepDescriptor { + + @Override + public String getFunctionName() { + return "updateLock"; + } + + @NonNull + @Override + public String getDisplayName() { + return Messages.UpdateLockStep_displayName(); + } + + @Override + public boolean takesImplicitBlockArgument() { + return false; + } + + @Override + public Set> getRequiredContext() { + return Collections.singleton(TaskListener.class); + } + + /** + * Provides auto-completion for resource names. + */ + @RequirePOST + public AutoCompletionCandidates doAutoCompleteResource( + @QueryParameter String value, @AncestorInPath Item item) { + return RequiredResourcesProperty.DescriptorImpl.doAutoCompleteResourceNames(value, item); + } + + /** + * Validates the resource name. + */ + @RequirePOST + public FormValidation doCheckResource(@QueryParameter String value, @AncestorInPath Item item) { + if (item != null) { + item.checkPermission(Item.CONFIGURE); + } else { + Jenkins.get().checkPermission(Jenkins.ADMINISTER); + } + value = Util.fixEmptyAndTrim(value); + if (value == null) { + return FormValidation.error(Messages.UpdateLockStep_error_resourceRequired()); + } + return FormValidation.ok(); + } + + /** + * Validates addLabels option - cannot be used with setLabels. + */ + @RequirePOST + public FormValidation doCheckAddLabels( + @QueryParameter String value, @QueryParameter String setLabels, @AncestorInPath Item item) { + if (item != null) { + item.checkPermission(Item.CONFIGURE); + } else { + Jenkins.get().checkPermission(Jenkins.ADMINISTER); + } + return doCheckLabelOperation(value, setLabels); + } + + /** + * Validates removeLabels option - cannot be used with setLabels. + */ + @RequirePOST + public FormValidation doCheckRemoveLabels( + @QueryParameter String value, @QueryParameter String setLabels, @AncestorInPath Item item) { + if (item != null) { + item.checkPermission(Item.CONFIGURE); + } else { + Jenkins.get().checkPermission(Jenkins.ADMINISTER); + } + return doCheckLabelOperation(value, setLabels); + } + + private FormValidation doCheckLabelOperation(String value, String setLabels) { + if (Util.fixEmptyAndTrim(value) != null && Util.fixEmptyAndTrim(setLabels) != null) { + return FormValidation.error(Messages.UpdateLockStep_error_setLabelsConflict()); + } + return FormValidation.ok(); + } + + /** + * Validates deleteResource option - cannot be combined with other modify options. + */ + @RequirePOST + public FormValidation doCheckDeleteResource( + @QueryParameter boolean value, + @QueryParameter String setLabels, + @QueryParameter String addLabels, + @QueryParameter String removeLabels, + @QueryParameter String setNote, + @QueryParameter boolean createResource, + @AncestorInPath Item item) { + if (item != null) { + item.checkPermission(Item.CONFIGURE); + } else { + Jenkins.get().checkPermission(Jenkins.ADMINISTER); + } + if (!value) { + return FormValidation.ok(); + } + if (createResource) { + return FormValidation.error(Messages.UpdateLockStep_error_deleteAndCreateConflict()); + } + if (Util.fixEmptyAndTrim(setLabels) != null + || Util.fixEmptyAndTrim(addLabels) != null + || Util.fixEmptyAndTrim(removeLabels) != null + || Util.fixEmptyAndTrim(setNote) != null) { + return FormValidation.error(Messages.UpdateLockStep_error_deleteWithOtherOptions()); + } + return FormValidation.ok(); + } + } +} diff --git a/src/main/java/org/jenkins/plugins/lockableresources/UpdateLockStepExecution.java b/src/main/java/org/jenkins/plugins/lockableresources/UpdateLockStepExecution.java new file mode 100644 index 000000000..e05023d8b --- /dev/null +++ b/src/main/java/org/jenkins/plugins/lockableresources/UpdateLockStepExecution.java @@ -0,0 +1,192 @@ +/* + * The MIT License + * + * See the "LICENSE.txt" file for full copyright and license information. + */ +package org.jenkins.plugins.lockableresources; + +import hudson.model.TaskListener; +import java.io.PrintStream; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.logging.Level; +import java.util.logging.Logger; +import java.util.stream.Collectors; +import org.jenkinsci.plugins.workflow.steps.AbstractStepExecutionImpl; +import org.jenkinsci.plugins.workflow.steps.StepContext; + +/** + * Execution logic for the {@link UpdateLockStep}. + */ +public class UpdateLockStepExecution extends AbstractStepExecutionImpl { + + private static final long serialVersionUID = 1583205294263267002L; + private static final Logger LOGGER = Logger.getLogger(UpdateLockStepExecution.class.getName()); + + private final UpdateLockStep step; + + public UpdateLockStepExecution(UpdateLockStep step, StepContext context) { + super(context); + this.step = step; + } + + @Override + public boolean start() throws Exception { + step.validate(); + + PrintStream logger = getContext().get(TaskListener.class).getLogger(); + LockableResourcesManager lrm = LockableResourcesManager.get(); + + synchronized (LockableResourcesManager.syncResources) { + if (step.isDeleteResource()) { + handleDeleteResource(lrm, logger); + } else { + handleUpdateResource(lrm, logger); + } + } + + getContext().onSuccess(null); + return true; + } + + /** + * Handles the deletion of a resource. + */ + private void handleDeleteResource(LockableResourcesManager lrm, PrintStream logger) { + String resourceName = step.getResource(); + LockableResource resource = lrm.fromName(resourceName); + + if (resource == null) { + LockableResourcesManager.printLogs( + "Resource [" + resourceName + "] does not exist, nothing to delete.", + Level.WARNING, + LOGGER, + logger); + return; + } + + if (resource.isLocked() || resource.isQueued()) { + LockableResourcesManager.printLogs( + "Resource [" + resourceName + "] is currently locked or queued, cannot delete.", + Level.WARNING, + LOGGER, + logger); + throw new IllegalStateException(Messages.UpdateLockStep_error_resourceInUse(resourceName)); + } + + if (resource.isReserved()) { + LockableResourcesManager.printLogs( + "Resource [" + resourceName + "] is currently reserved, cannot delete.", + Level.WARNING, + LOGGER, + logger); + throw new IllegalStateException(Messages.UpdateLockStep_error_resourceReserved(resourceName)); + } + + lrm.removeResources(Collections.singletonList(resource)); + lrm.save(); + + LockableResourcesManager.printLogs("Resource [" + resourceName + "] deleted.", Level.FINE, LOGGER, logger); + } + + /** + * Handles updating an existing resource or creating a new one. + */ + private void handleUpdateResource(LockableResourcesManager lrm, PrintStream logger) { + String resourceName = step.getResource(); + LockableResource resource = lrm.fromName(resourceName); + + // Create resource if it doesn't exist and createResource is true + if (resource == null) { + if (step.isCreateResource()) { + lrm.createResource(resourceName); + resource = lrm.fromName(resourceName); + if (resource != null) { + // Make it persistent (not ephemeral) since it was explicitly created + resource.setEphemeral(false); + LockableResourcesManager.printLogs( + "Resource [" + resourceName + "] created.", Level.FINE, LOGGER, logger); + } + } else { + LockableResourcesManager.printLogs( + "Resource [" + resourceName + "] does not exist. Use createResource: true to create it.", + Level.WARNING, + LOGGER, + logger); + throw new IllegalStateException(Messages.UpdateLockStep_error_resourceNotFound(resourceName)); + } + } + + if (resource == null) { + throw new IllegalStateException(Messages.UpdateLockStep_error_resourceNotFound(resourceName)); + } + + // Handle labels + updateLabels(resource, logger); + + // Handle note + if (step.getSetNote() != null) { + resource.setNote(step.getSetNote()); + LockableResourcesManager.printLogs( + "Resource [" + resourceName + "] note updated.", Level.FINE, LOGGER, logger); + } + + lrm.save(); + LockableResourcesManager.printLogs("Resource [" + resourceName + "] updated.", Level.FINE, LOGGER, logger); + } + + /** + * Updates labels on the resource based on step configuration. + */ + private void updateLabels(LockableResource resource, PrintStream logger) { + String resourceName = resource.getName(); + + if (step.getSetLabels() != null) { + // setLabels replaces all existing labels + List newLabels = parseLabels(step.getSetLabels()); + resource.setLabelsFromString(String.join(" ", newLabels)); + LockableResourcesManager.printLogs( + "Resource [" + resourceName + "] labels set to: " + newLabels, Level.FINE, LOGGER, logger); + } else if (step.getAddLabels() != null || step.getRemoveLabels() != null) { + // addLabels/removeLabels modify existing labels + List currentLabels = new ArrayList<>(resource.getLabelsAsList()); + + if (step.getAddLabels() != null) { + List labelsToAdd = parseLabels(step.getAddLabels()); + for (String label : labelsToAdd) { + if (!currentLabels.contains(label)) { + currentLabels.add(label); + } + } + LockableResourcesManager.printLogs( + "Resource [" + resourceName + "] added labels: " + labelsToAdd, Level.FINE, LOGGER, logger); + } + + if (step.getRemoveLabels() != null) { + List labelsToRemove = parseLabels(step.getRemoveLabels()); + currentLabels.removeAll(labelsToRemove); + LockableResourcesManager.printLogs( + "Resource [" + resourceName + "] removed labels: " + labelsToRemove, + Level.FINE, + LOGGER, + logger); + } + + resource.setLabelsFromString(currentLabels.stream().collect(Collectors.joining(" "))); + } + } + + /** + * Parses a whitespace-separated label string into a list of labels. + */ + private List parseLabels(String labels) { + if (labels == null || labels.trim().isEmpty()) { + return new ArrayList<>(); + } + return Arrays.stream(labels.trim().split("\\s+")) + .filter(l -> !l.isEmpty()) + .collect(Collectors.toList()); + } +} diff --git a/src/main/java/org/jenkins/plugins/lockableresources/actions/LockableResourcesManagementLink.java b/src/main/java/org/jenkins/plugins/lockableresources/actions/LockableResourcesManagementLink.java new file mode 100644 index 000000000..fd45889bf --- /dev/null +++ b/src/main/java/org/jenkins/plugins/lockableresources/actions/LockableResourcesManagementLink.java @@ -0,0 +1,65 @@ +package org.jenkins.plugins.lockableresources.actions; + +import edu.umd.cs.findbugs.annotations.NonNull; +import hudson.Extension; +import hudson.model.ManagementLink; +import hudson.model.RootAction; +import hudson.security.Permission; +import jenkins.model.Jenkins; +import org.jenkins.plugins.lockableresources.Messages; +import org.kohsuke.accmod.Restricted; +import org.kohsuke.accmod.restrictions.NoExternalUse; + +/** + * ManagementLink that renders the Lockable Resources page inside the Manage Jenkins + * settings-subpage layout (with the Manage Jenkins sidebar). This keeps the plugin + * discoverable and integrated within the management UI for admins. + * + *

Non-admin users with only the VIEW permission access the page via the + * {@link LockableResourcesRootAction} at {@code /lockable-resources/} instead. + */ +@Extension +@Restricted(NoExternalUse.class) +public class LockableResourcesManagementLink extends ManagementLink { + + @Override + public String getIconFileName() { + return Jenkins.get().hasPermission(LockableResourcesRootAction.VIEW) ? LockableResourcesRootAction.ICON : null; + } + + @Override + public String getDisplayName() { + return Messages.LockableResourcesRootAction_PermissionGroup(); + } + + @Override + public String getUrlName() { + return "lockable-resources"; + } + + @Override + public String getDescription() { + return Messages.LockableResourcesRootAction_ManagementLink_Description(); + } + + @NonNull + @Override + public Category getCategory() { + return Category.CONFIGURATION; + } + + @NonNull + @Override + public Permission getRequiredPermission() { + return LockableResourcesRootAction.VIEW; + } + + /** + * Returns the RootAction instance so the Jelly view can delegate content rendering. + * Used by {@code index.jelly}. + */ + @Restricted(NoExternalUse.class) + public LockableResourcesRootAction getAction() { + return Jenkins.get().getExtensionList(RootAction.class).get(LockableResourcesRootAction.class); + } +} diff --git a/src/main/java/org/jenkins/plugins/lockableresources/actions/LockableResourcesRootAction.java b/src/main/java/org/jenkins/plugins/lockableresources/actions/LockableResourcesRootAction.java index 08770d5ae..aba91661a 100644 --- a/src/main/java/org/jenkins/plugins/lockableresources/actions/LockableResourcesRootAction.java +++ b/src/main/java/org/jenkins/plugins/lockableresources/actions/LockableResourcesRootAction.java @@ -1,170 +1,1510 @@ -/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * - * Copyright (c) 2013, 6WIND S.A. All rights reserved. * - * * - * This file is part of the Jenkins Lockable Resources Plugin and is * - * published under the MIT license. * - * * - * See the "LICENSE.txt" file for more information. * - * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ package org.jenkins.plugins.lockableresources.actions; +import edu.umd.cs.findbugs.annotations.CheckForNull; +import edu.umd.cs.findbugs.annotations.NonNull; import hudson.Extension; +import hudson.ExtensionList; +import hudson.Util; +import hudson.model.Api; +import hudson.model.Descriptor; import hudson.model.RootAction; -import hudson.model.User; -import hudson.security.AccessDeniedException2; +import hudson.model.Run; +import hudson.security.AccessDeniedException3; import hudson.security.Permission; import hudson.security.PermissionGroup; import hudson.security.PermissionScope; - +import jakarta.servlet.ServletException; import java.io.IOException; import java.util.ArrayList; +import java.util.Collections; +import java.util.Date; +import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Locale; +import java.util.Map; import java.util.Set; - -import javax.servlet.ServletException; - +import java.util.logging.Logger; +import java.util.stream.Collectors; import jenkins.model.Jenkins; - +import org.jenkins.plugins.lockableresources.FreeDeadJobs; import org.jenkins.plugins.lockableresources.LockableResource; +import org.jenkins.plugins.lockableresources.LockableResourceProperty; import org.jenkins.plugins.lockableresources.LockableResourcesManager; import org.jenkins.plugins.lockableresources.Messages; -import org.kohsuke.stapler.StaplerRequest; -import org.kohsuke.stapler.StaplerResponse; +import org.jenkins.plugins.lockableresources.queue.LockableResourcesStruct; +import org.jenkins.plugins.lockableresources.queue.QueuedContextStruct; +import org.jenkins.plugins.lockableresources.remote.RemoteLockManager; +import org.jenkins.plugins.lockableresources.remote.RemoteLockRequest; +import org.jenkins.plugins.lockableresources.remote.RemoteQueueEntry; +import org.jenkinsci.plugins.scriptsecurity.sandbox.groovy.SecureGroovyScript; +import org.kohsuke.accmod.Restricted; +import org.kohsuke.accmod.restrictions.NoExternalUse; +import org.kohsuke.stapler.StaplerRequest2; +import org.kohsuke.stapler.StaplerResponse2; +import org.kohsuke.stapler.export.Exported; +import org.kohsuke.stapler.export.ExportedBean; +import org.kohsuke.stapler.interceptor.RequirePOST; +import org.kohsuke.stapler.verb.GET; @Extension +@ExportedBean public class LockableResourcesRootAction implements RootAction { - public static final PermissionGroup PERMISSIONS_GROUP = new PermissionGroup( - LockableResourcesManager.class, Messages._LockableResourcesRootAction_PermissionGroup()); - public static final Permission UNLOCK = new Permission(PERMISSIONS_GROUP, - Messages.LockableResourcesRootAction_UnlockPermission(), - Messages._LockableResourcesRootAction_UnlockPermission_Description(), Jenkins.ADMINISTER, - PermissionScope.JENKINS); - public static final Permission RESERVE = new Permission(PERMISSIONS_GROUP, - Messages.LockableResourcesRootAction_ReservePermission(), - Messages._LockableResourcesRootAction_ReservePermission_Description(), Jenkins.ADMINISTER, - PermissionScope.JENKINS); - - public static final String ICON = "/plugin/lockable-resources/img/device-24x24.png"; - - public String getIconFileName() { - if (User.current() != null) { - // only show if logged in - return ICON; - } else { - return null; - } - } - - public String getUserName() { - User current = User.current(); - if (current != null) - return current.getFullName(); - else - return null; - } - - public String getDisplayName() { - return "Lockable Resources"; - } - - public String getUrlName() { - return "lockable-resources"; - } - - public List getResources() { - return LockableResourcesManager.get().getResources(); - } - - public int getFreeResourceAmount(String label) { - return LockableResourcesManager.get().getFreeResourceAmount(label); - } - - public Set getAllLabels() { - return LockableResourcesManager.get().getAllLabels(); - } - - public int getNumberOfAllLabels() { - return LockableResourcesManager.get().getAllLabels().size(); - } - - public void doUnlock(StaplerRequest req, StaplerResponse rsp) - throws IOException, ServletException { - Jenkins.getInstance().checkPermission(UNLOCK); - - String name = req.getParameter("resource"); - LockableResource r = LockableResourcesManager.get().fromName(name); - if (r == null) { - rsp.sendError(404, "Resource not found " + name); - return; - } - - List resources = new ArrayList(); - resources.add(r); - LockableResourcesManager.get().unlock(resources, null); - - rsp.forwardToPreviousPage(req); - } - - public void doReserve(StaplerRequest req, StaplerResponse rsp) - throws IOException, ServletException { - Jenkins.getInstance().checkPermission(RESERVE); - - String name = req.getParameter("resource"); - LockableResource r = LockableResourcesManager.get().fromName(name); - if (r == null) { - rsp.sendError(404, "Resource not found " + name); - return; - } - - List resources = new ArrayList(); - resources.add(r); - String userName = getUserName(); - if (userName != null) - LockableResourcesManager.get().reserve(resources, userName); - - rsp.forwardToPreviousPage(req); - } - - public void doUnreserve(StaplerRequest req, StaplerResponse rsp) - throws IOException, ServletException { - Jenkins.getInstance().checkPermission(RESERVE); - - String name = req.getParameter("resource"); - LockableResource r = LockableResourcesManager.get().fromName(name); - if (r == null) { - rsp.sendError(404, "Resource not found " + name); - return; - } - - String userName = getUserName(); - if ((userName == null || !userName.equals(r.getReservedBy())) - && !Jenkins.getInstance().hasPermission(Jenkins.ADMINISTER)) - throw new AccessDeniedException2(Jenkins.getAuthentication(), - RESERVE); - - List resources = new ArrayList(); - resources.add(r); - LockableResourcesManager.get().unreserve(resources); - - rsp.forwardToPreviousPage(req); - } - - public void doReset(StaplerRequest req, StaplerResponse rsp) - throws IOException, ServletException { - Jenkins.getInstance().checkPermission(UNLOCK); - - String name = req.getParameter("resource"); - LockableResource r = LockableResourcesManager.get().fromName(name); - if (r == null) { - rsp.sendError(404, "Resource not found " + name); - return; - } - - List resources = new ArrayList(); - resources.add(r); - LockableResourcesManager.get().reset(resources); - - rsp.forwardToPreviousPage(req); - } + private static final Logger LOGGER = Logger.getLogger(LockableResourcesRootAction.class.getName()); + + public static final PermissionGroup PERMISSIONS_GROUP = new PermissionGroup( + LockableResourcesManager.class, Messages._LockableResourcesRootAction_PermissionGroup()); + public static final Permission UNLOCK = new Permission( + PERMISSIONS_GROUP, + "Unlock", + Messages._LockableResourcesRootAction_UnlockPermission_Description(), + Jenkins.ADMINISTER, + PermissionScope.JENKINS); + public static final Permission RESERVE = new Permission( + PERMISSIONS_GROUP, + "Reserve", + Messages._LockableResourcesRootAction_ReservePermission_Description(), + Jenkins.ADMINISTER, + PermissionScope.JENKINS); + public static final Permission STEAL = new Permission( + PERMISSIONS_GROUP, + "Steal", + Messages._LockableResourcesRootAction_StealPermission_Description(), + Jenkins.ADMINISTER, + PermissionScope.JENKINS); + public static final Permission VIEW = new Permission( + PERMISSIONS_GROUP, + "View", + Messages._LockableResourcesRootAction_ViewPermission_Description(), + Jenkins.READ, + PermissionScope.JENKINS); + public static final Permission QUEUE = new Permission( + PERMISSIONS_GROUP, + "Queue", + Messages._LockableResourcesRootAction_QueueChangeOrderPermission_Description(), + Jenkins.ADMINISTER, + PermissionScope.JENKINS); + public static final Permission CONFIGURE = new Permission( + PERMISSIONS_GROUP, + "Configure", + Messages._LockableResourcesRootAction_ConfigurePermission_Description(), + Jenkins.ADMINISTER, + PermissionScope.JENKINS); + /** + * Gates the remote lock REST API ({@code /lockable-resources/remote/v1/}). + * Grant this to the machine users whose API tokens remote client controllers + * use as {@code credentialsId}; this makes remote clients auditable in the + * authorization matrix instead of being implied by plain READ. + */ + public static final Permission REMOTE = new Permission( + PERMISSIONS_GROUP, + "RemoteUse", + Messages._LockableResourcesRootAction_RemoteUsePermission_Description(), + Jenkins.ADMINISTER, + PermissionScope.JENKINS); + + public static final String ICON = "symbol-lock-closed"; + + @CheckForNull + @Override + public String getIconFileName() { + return Jenkins.get().hasPermission(VIEW) ? ICON : null; + } + + /** + * Returns the ManagementLink instance for use by the Jelly view when rendering + * within the Manage Jenkins layout. + * Used by {@code index.jelly}. + */ + @Restricted(NoExternalUse.class) + public LockableResourcesManagementLink getManagementLink() { + return ExtensionList.lookupSingleton(LockableResourcesManagementLink.class); + } + + public Api getApi() { + return new Api(this); + } + + @CheckForNull + public String getUserName() { + return LockableResource.getUserName(); + } + + @CheckForNull + @Override + public String getDisplayName() { + return Jenkins.get().hasPermission(VIEW) ? Messages.LockableResourcesRootAction_PermissionGroup() : null; + } + + @Override + public String getUrlName() { + return "lockable-resources"; + } + + // --------------------------------------------------------------------------- + private static String formatDuration(long millis) { + long seconds = millis / 1000; + if (seconds < 60) return seconds + "s"; + long minutes = seconds / 60; + if (minutes < 60) return minutes + "m"; + long hours = minutes / 60; + long remainingMin = minutes % 60; + if (hours < 24) return hours + "h " + remainingMin + "m"; + long days = hours / 24; + long remainingHours = hours % 24; + return days + "d " + remainingHours + "h"; + } + + // --------------------------------------------------------------------------- + /** Returns a summary of resource states for the overview tab. */ + @Restricted(NoExternalUse.class) // used by jelly + public Summary getSummary() { + Jenkins.get().checkPermission(VIEW); + + int locked = 0; + int reserved = 0; + int queued = 0; + int free = 0; + int total = 0; + + for (LockableResource r : LockableResourcesManager.get().getReadOnlyResources()) { + total++; + if (r.getReservedBy() != null) { + reserved++; + } else if (r.isLocked()) { + locked++; + } else { + // Queued resources are counted as free since the queued state + // is transient and nearly invisible in practice. + free++; + } + } + + int queueItems = 0; + int distinctBuildsWaiting = 0; + String oldestBuildName = null; + String oldestBuildUrl = null; + long oldestQueuedAt = Long.MAX_VALUE; + Map resourceDemand = new HashMap<>(); + for (QueuedContextStruct context : LockableResourcesManager.get().getCurrentQueuedContext()) { + queueItems += context.getResources().size(); + distinctBuildsWaiting++; + Run run = context.getBuild(); + for (LockableResourcesStruct rs : context.getResources()) { + if (rs.queuedAt > 0 && rs.queuedAt < oldestQueuedAt) { + oldestQueuedAt = rs.queuedAt; + oldestBuildName = (run != null) ? run.getFullDisplayName() : null; + oldestBuildUrl = (run != null) ? run.getUrl() : null; + } + for (LockableResource r : rs.required) { + resourceDemand.merge(r.getName(), 1, (a, b) -> a + b); + } + } + } + String oldestWaitTime = null; + if (oldestBuildName != null && oldestQueuedAt < Long.MAX_VALUE) { + long elapsed = System.currentTimeMillis() - oldestQueuedAt; + oldestWaitTime = formatDuration(elapsed); + } + String mostContendedResource = null; + int mostContendedCount = 0; + for (Map.Entry entry : resourceDemand.entrySet()) { + if (entry.getValue() > mostContendedCount) { + mostContendedCount = entry.getValue(); + mostContendedResource = entry.getKey(); + } + } + + int labelsCount = getLabelsList().size(); + + List topLabels = getLabelsList().values().stream() + .sorted((a, b) -> Integer.compare(b.getAssigned(), a.getAssigned())) + .limit(3) + .collect(Collectors.toList()); + + return new Summary( + total, + locked, + reserved, + queued, + free, + queueItems, + distinctBuildsWaiting, + labelsCount, + topLabels, + oldestBuildName, + oldestBuildUrl, + oldestWaitTime, + mostContendedResource, + mostContendedCount); + } + + // --------------------------------------------------------------------------- + @Restricted(NoExternalUse.class) + public static final class Summary { + private final int total; + private final int locked; + private final int reserved; + private final int queued; + private final int free; + private final int queueItems; + private final int distinctBuildsWaiting; + private final int labelsCount; + private final List topLabels; + private final String oldestBuildName; + private final String oldestBuildUrl; + private final String oldestWaitTime; + private final String mostContendedResource; + private final int mostContendedCount; + + Summary( + int total, + int locked, + int reserved, + int queued, + int free, + int queueItems, + int distinctBuildsWaiting, + int labelsCount, + List topLabels, + String oldestBuildName, + String oldestBuildUrl, + String oldestWaitTime, + String mostContendedResource, + int mostContendedCount) { + this.total = total; + this.locked = locked; + this.reserved = reserved; + this.queued = queued; + this.free = free; + this.queueItems = queueItems; + this.distinctBuildsWaiting = distinctBuildsWaiting; + this.labelsCount = labelsCount; + this.topLabels = topLabels; + this.oldestBuildName = oldestBuildName; + this.oldestBuildUrl = oldestBuildUrl; + this.oldestWaitTime = oldestWaitTime; + this.mostContendedResource = mostContendedResource; + this.mostContendedCount = mostContendedCount; + } + + public int getTotal() { + return total; + } + + public int getLocked() { + return locked; + } + + public int getReserved() { + return reserved; + } + + public int getQueued() { + return queued; + } + + public int getFree() { + return free; + } + + public int getQueueItems() { + return queueItems; + } + + public int getDistinctBuildsWaiting() { + return distinctBuildsWaiting; + } + + public int getLabelsCount() { + return labelsCount; + } + + public List getTopLabels() { + return topLabels; + } + + public int getLockedPct() { + return total > 0 ? (locked * 100) / total : 0; + } + + public int getReservedPct() { + return total > 0 ? (reserved * 100) / total : 0; + } + + public int getFreePct() { + return total > 0 ? (free * 100) / total : 0; + } + + public String getOldestBuildName() { + return oldestBuildName; + } + + public String getOldestBuildUrl() { + return oldestBuildUrl; + } + + public String getOldestWaitTime() { + return oldestWaitTime; + } + + public String getMostContendedResource() { + return mostContendedResource; + } + + public int getMostContendedCount() { + return mostContendedCount; + } + } + + // --------------------------------------------------------------------------- + /** + * Get a list of resources + * + * @return All resources. + */ + @Exported + @Restricted(NoExternalUse.class) // used by jelly + public List getResources() { + return LockableResourcesManager.get().getReadOnlyResources(); + } + + // --------------------------------------------------------------------------- + /** + * Get a list of all labels + * + * @return All possible labels. + */ + @Restricted(NoExternalUse.class) // used by jelly + public LinkedHashMap getLabelsList() { + LinkedHashMap map = new LinkedHashMap<>(); + + for (LockableResource r : LockableResourcesManager.get().getReadOnlyResources()) { + if (r == null || r.getName().isEmpty()) { + continue; // defensive, shall never happens, but ... + } + List assignedLabels = r.getLabelsAsList(); + if (assignedLabels.isEmpty()) { + continue; + } + + for (String labelString : assignedLabels) { + if (labelString == null || labelString.isEmpty()) { + continue; // defensive, shall never happens, but ... + } + LockableResourcesLabel label = map.get(labelString); + if (label == null) { + label = new LockableResourcesLabel(labelString); + } + + label.update(r); + + map.put(labelString, label); + } + } + + return map; + } + + // --------------------------------------------------------------------------- + public static class LockableResourcesLabel { + String name; + int free; + int assigned; + + // ------------------------------------------------------------------------- + public LockableResourcesLabel(String _name) { + this.name = _name; + this.free = 0; + this.assigned = 0; + } + + // ------------------------------------------------------------------------- + public void update(LockableResource resource) { + this.assigned++; + if (resource.isFree()) free++; + } + + // ------------------------------------------------------------------------- + public String getName() { + return this.name; + } + + // ------------------------------------------------------------------------- + public int getFree() { + return this.free; + } + + // ------------------------------------------------------------------------- + public int getAssigned() { + return this.assigned; + } + + // ------------------------------------------------------------------------- + public int getPercentage() { + if (this.assigned == 0) { + return this.assigned; + } + return (int) ((double) this.free / (double) this.assigned * 100); + } + } + + // --------------------------------------------------------------------------- + // used by by + // src\main\resources\org\jenkins\plugins\lockableresources\actions\LockableResourcesRootAction\tableResources\table.jelly + @Restricted(NoExternalUse.class) + public LockableResource getResource(final String resourceName) { + return LockableResourcesManager.get().fromName(resourceName); + } + + // --------------------------------------------------------------------------- + /** + * Get amount of free resources assigned to given *labelString* + * + * @param labelString Label to search. + * @return Amount of free labels. + */ + @Restricted(NoExternalUse.class) // used by jelly + @Deprecated // slow down plugin execution due concurrent modification checks + public int getFreeResourceAmount(final String labelString) { + this.informPerformanceIssue(); + LockableResourcesLabel label = this.getLabelsList().get(labelString); + return (label == null) ? 0 : label.getFree(); + } + + // --------------------------------------------------------------------------- + /** + * Get percentage (0-100) usage of resources assigned to given *labelString* + * + *

Used by {@code actions/LockableResourcesRootAction/index.jelly} + * + * @since 2.19 + * @param labelString Label to search. + * @return Percentage usages of *labelString* around all resources + */ + @Restricted(NoExternalUse.class) // used by jelly + @Deprecated // slow down plugin execution due concurrent modification checks + public int getFreeResourcePercentage(final String labelString) { + this.informPerformanceIssue(); + LockableResourcesLabel label = this.getLabelsList().get(labelString); + return (label == null) ? 0 : label.getPercentage(); + } + + // --------------------------------------------------------------------------- + /** + * Get all existing labels as list. + * + * @return All possible labels. + */ + @Restricted(NoExternalUse.class) // used by jelly + @Deprecated // slow down plugin execution due concurrent modification checks + public Set getAllLabels() { + this.informPerformanceIssue(); + return LockableResourcesManager.get().getAllLabels(); + } + + // --------------------------------------------------------------------------- + /** + * Get amount of all labels. + * + * @return Amount of all labels. + */ + @Restricted(NoExternalUse.class) // used by jelly + @Deprecated // slow down plugin execution due concurrent modification checks + public int getNumberOfAllLabels() { + this.informPerformanceIssue(); + return this.getLabelsList().size(); + } + + // --------------------------------------------------------------------------- + /** + * Get amount of resources assigned to given *labelString* + * + *

Used by {@code actions/LockableResourcesRootAction/index.jelly} + * + * @param labelString Label to search. + * @return Amount of assigned resources. + */ + @Restricted(NoExternalUse.class) // used by jelly + @Deprecated // slow down plugin execution due concurrent modification checks + public int getAssignedResourceAmount(String labelString) { + this.informPerformanceIssue(); + return LockableResourcesManager.get().getResourcesWithLabel(labelString).size(); + } + + // --------------------------------------------------------------------------- + private void informPerformanceIssue() { + String method = Thread.currentThread().getStackTrace()[2].getMethodName(); + StringBuilder buf = new StringBuilder(); + for (StackTraceElement st : Thread.currentThread().getStackTrace()) { + buf.append("\n").append(st); + } + LOGGER.warning("lockable-resources-plugin: The method " + + method + + " has been deprecated due performance issues. When you see this message, please inform plugin developers:" + + buf); + } + + // --------------------------------------------------------------------------- + @Restricted(NoExternalUse.class) // used by jelly + public Queue getQueue() throws Descriptor.FormException { + List currentQueueContext = + List.copyOf(LockableResourcesManager.get().getCurrentQueuedContext()); + Queue queue = new Queue(); + + for (QueuedContextStruct context : currentQueueContext) { + for (LockableResourcesStruct resourceStruct : context.getResources()) { + queue.add(resourceStruct, context); + } + } + + for (RemoteQueueEntry remoteEntry : LockableResourcesManager.get().getCurrentRemoteQueueEntries()) { + queue.add(remoteEntry); + } + + return queue; + } + + // --------------------------------------------------------------------------- + public static class Queue { + + List queue; + QueueStruct oldest; + + // ------------------------------------------------------------------------- + @Restricted(NoExternalUse.class) // used by jelly + public Queue() { + this.queue = new ArrayList<>(); + } + + // ------------------------------------------------------------------------- + @Restricted(NoExternalUse.class) // used by jelly + public void add(final LockableResourcesStruct resourceStruct, final QueuedContextStruct context) + throws Descriptor.FormException { + QueueStruct queueStruct = new QueueStruct(resourceStruct, context); + queue.add(queueStruct); + if (resourceStruct.queuedAt == 0) { + // Older versions of this plugin might miss this information. + // Therefore skip it here. + return; + } + if (oldest == null || oldest.getQueuedAt() > queueStruct.getQueuedAt()) { + oldest = queueStruct; + } + } + + @Restricted(NoExternalUse.class) // used by jelly + public void add(final RemoteQueueEntry remoteEntry) { + QueueStruct queueStruct = new QueueStruct(remoteEntry); + queue.add(queueStruct); + if (queueStruct.getQueuedAt() == 0) { + return; + } + if (oldest == null || oldest.getQueuedAt() > queueStruct.getQueuedAt()) { + oldest = queueStruct; + } + } + + // ------------------------------------------------------------------------- + @Restricted(NoExternalUse.class) // used by jelly + public List getAll() { + return Collections.unmodifiableList(this.queue); + } + + // ------------------------------------------------------------------------- + @Restricted(NoExternalUse.class) // used by jelly + public QueueStruct getOldest() { + return this.oldest; + } + + // ------------------------------------------------------------------------- + @Restricted(NoExternalUse.class) // used by jelly + public static class QueueStruct { + List requiredResources; + String requiredLabel; + String groovyScript; + String requiredNumber; + long queuedAt = 0; + int priority = 0; + String id = null; + Run build; + boolean remote = false; + String requestedBy; + String requestedByUrl; + String remoteReason; + String remoteRequest; + + public QueueStruct(final LockableResourcesStruct resourceStruct, final QueuedContextStruct context) + throws Descriptor.FormException { + this.requiredResources = resourceStruct.required; + this.requiredLabel = resourceStruct.label; + this.requiredNumber = resourceStruct.requiredNumber; + this.queuedAt = resourceStruct.queuedAt; + this.build = context.getBuild(); + this.priority = context.getPriority(); + this.id = context.getId(); + this.requestedBy = build != null ? build.getFullDisplayName() : ""; + this.requestedByUrl = build != null ? build.getUrl() : ""; + + final SecureGroovyScript systemGroovyScript = resourceStruct.getResourceMatchScript(); + if (systemGroovyScript != null) { + this.groovyScript = systemGroovyScript.getScript(); + } + } + + public QueueStruct(@NonNull final RemoteQueueEntry remoteEntry) { + this.queuedAt = remoteEntry.getRecord().getEnqueuedAt(); + this.priority = remoteEntry.getPriority(); + this.id = remoteEntry.getLockId(); + this.remote = true; + + RemoteLockRequest lr = remoteEntry.getLockRequest(); + this.remoteReason = Util.fixEmptyAndTrim(lr.getReason()); + this.remoteRequest = buildRemoteRequestText(lr); + + String cid = Util.fixEmptyAndTrim(remoteEntry.getRecord().getClientId()); + this.requestedBy = cid != null ? cid : "Remote API"; + this.requestedByUrl = ""; + } + + // ----------------------------------------------------------------------- + /** */ + @Restricted(NoExternalUse.class) // used by jelly + public List getRequiredResources() { + return this.requiredResources; + } + + // ----------------------------------------------------------------------- + /** */ + @NonNull + @Restricted(NoExternalUse.class) // used by jelly + public String getRequiredLabel() { + return this.requiredLabel == null ? "N/A" : this.requiredLabel; + } + + // ----------------------------------------------------------------------- + /** */ + @NonNull + @Restricted(NoExternalUse.class) // used by jelly + public String getRequiredNumber() { + return this.requiredNumber == null ? "0" : this.requiredNumber; + } + + // ----------------------------------------------------------------------- + /** */ + @NonNull + @Restricted(NoExternalUse.class) // used by jelly + public String getGroovyScript() { + return this.groovyScript == null ? "N/A" : this.groovyScript; + } + + // ----------------------------------------------------------------------- + /** */ + @Restricted(NoExternalUse.class) // used by jelly + public Run getBuild() { + return this.build; + } + + // ----------------------------------------------------------------------- + /** */ + @Restricted(NoExternalUse.class) // used by jelly + public long getQueuedAt() { + return this.queuedAt; + } + + // ----------------------------------------------------------------------- + /** Check if the queue takes too long. At the moment "too long" means over 1 hour. */ + @Restricted(NoExternalUse.class) // used by jelly + public boolean takeTooLong() { + return (new Date().getTime() - this.queuedAt) > 3600000L; + } + + // ----------------------------------------------------------------------- + /** Returns timestamp when the resource has been added into queue. */ + @Restricted(NoExternalUse.class) // used by jelly + public Date getQueuedTimestamp() { + return new Date(this.queuedAt); + } + + // ----------------------------------------------------------------------- + /** Returns queue priority. */ + @Restricted(NoExternalUse.class) // used by jelly + public int getPriority() { + if (this.id == null) { + // defensive + // in case of jenkins update from older version and you have some queue + // might happens, that there are no priority set + return 0; + } + return this.priority; + } + + // ----------------------------------------------------------------------- + /** Returns queue ID. */ + @Restricted(NoExternalUse.class) + public String getId() { + if (this.id == null) { + // defensive + // in case of jenkins update from older version and you have some queue + // might happens, that there are no priority set + return "NN"; + } + return this.id; + } + + @Restricted(NoExternalUse.class) + public boolean isRemote() { + return this.remote; + } + + @Restricted(NoExternalUse.class) + public String getRequestedBy() { + return this.requestedBy == null ? "" : this.requestedBy; + } + + @Restricted(NoExternalUse.class) + public String getRequestedByUrl() { + return this.requestedByUrl == null ? "" : this.requestedByUrl; + } + + @Restricted(NoExternalUse.class) + public String getRemoteReason() { + return this.remoteReason == null ? "" : this.remoteReason; + } + + @Restricted(NoExternalUse.class) + public String getRemoteRequest() { + return this.remoteRequest == null ? "" : this.remoteRequest; + } + + @Restricted(NoExternalUse.class) // used by jelly + public boolean resourcesMatch() { + return (requiredResources != null && requiredResources.size() > 0); + } + + // ----------------------------------------------------------------------- + @Restricted(NoExternalUse.class) // used by jelly + public boolean labelsMatch() { + return (requiredLabel != null); + } + + // ----------------------------------------------------------------------- + @Restricted(NoExternalUse.class) // used by jelly + public boolean scriptMatch() { + return (groovyScript != null && !groovyScript.isEmpty()); + } + + private static String buildRemoteRequestText(@NonNull RemoteLockRequest lr) { + List parts = new ArrayList<>(); + if (Util.fixEmptyAndTrim(lr.getResource()) != null) { + parts.add("resource=" + lr.getResource()); + } + if (Util.fixEmptyAndTrim(lr.getLabel()) != null) { + parts.add("label=" + lr.getLabel()); + if (lr.getQuantity() > 0) { + parts.add("quantity=" + lr.getQuantity()); + } + } + List extra = lr.getExtra(); + if (extra != null && !extra.isEmpty()) { + parts.add("extra=" + extra.size()); + } + if (parts.isEmpty()) { + return "remote request"; + } + return String.join(", ", parts); + } + } + } + + // --------------------------------------------------------------------------- + /** Returns current queue */ + @Restricted(NoExternalUse.class) // used by jelly + @Deprecated // slow down plugin execution due concurrent modification checks + public List getCurrentQueuedContext() { + return LockableResourcesManager.get().getCurrentQueuedContext(); + } + + // --------------------------------------------------------------------------- + /** Returns current queue */ + @Restricted(NoExternalUse.class) // used by jelly + @CheckForNull + @Deprecated // slow down plugin execution due concurrent modification checks + public LockableResourcesStruct getOldestQueue() { + LockableResourcesStruct oldest = null; + for (QueuedContextStruct context : this.getCurrentQueuedContext()) { + for (LockableResourcesStruct resourceStruct : context.getResources()) { + if (resourceStruct.queuedAt == 0) { + // Older versions of this plugin might miss this information. + // Therefore skip it here. + continue; + } + if (oldest == null || oldest.queuedAt > resourceStruct.queuedAt) { + oldest = resourceStruct; + } + } + } + return oldest; + } + + // --------------------------------------------------------------------------- + @RequirePOST + public void doUnlock(StaplerRequest2 req, StaplerResponse2 rsp) throws IOException, ServletException { + Jenkins.get().checkPermission(UNLOCK); + + List resources = this.getResourcesFromRequest(req, rsp); + if (resources == null) { + return; + } + + LockableResourcesManager.get().unlockResources(resources); + + rsp.forwardToPreviousPage(req); + } + + // --------------------------------------------------------------------------- + /** + * Force-releases a resource that is held by a remote lock (e.g. STALE lock with no active client). + * Called by the "Force Release Remote Lock" button in the resource table. + */ + @RequirePOST + public void doReleaseRemoteLock(StaplerRequest2 req, StaplerResponse2 rsp) throws IOException, ServletException { + Jenkins.get().checkPermission(UNLOCK); + + List resources = this.getResourcesFromRequest(req, rsp); + if (resources == null) { + return; + } + + for (LockableResource resource : resources) { + String lockId = resource.getRemoteLockedBy(); + if (lockId != null) { + LOGGER.info("doReleaseRemoteLock: force-releasing remote lock lockId=" + lockId + " resource=" + + resource.getName()); + RemoteLockManager.get().release(lockId); + } + } + + rsp.forwardToPreviousPage(req); + } + + // --------------------------------------------------------------------------- + @RequirePOST + public void doReserve(StaplerRequest2 req, StaplerResponse2 rsp) throws IOException, ServletException { + Jenkins.get().checkPermission(RESERVE); + + List resources = this.getResourcesFromRequest(req, rsp); + if (resources == null) { + return; + } + + String reason = Util.fixEmptyAndTrim(req.getParameter("reason")); + + LOGGER.info("doReserve called for resources=" + LockableResourcesManager.getResourcesNames(resources) + + " reason='" + reason + "' fromIP=" + req.getRemoteAddr()); + + String userName = getUserName(); + if (userName == null) { + LOGGER.warning("doReserve: userName is null (unauthenticated?) for resources=" + + LockableResourcesManager.getResourcesNames(resources)); + rsp.sendError(401, Messages.error_notAuthenticated()); + return; + } + + boolean ok = LockableResourcesManager.get().reserve(resources, userName, reason); + if (!ok) { + LOGGER.info("doReserve failed - resource already locked: " + + LockableResourcesManager.getResourcesNames(resources)); + rsp.sendError( + 423, Messages.error_resourceAlreadyLocked(LockableResourcesManager.getResourcesNames(resources))); + return; + } + LOGGER.info("doReserve succeeded for user='" + userName + "' resources=" + + LockableResourcesManager.getResourcesNames(resources)); + rsp.forwardToPreviousPage(req); + } + + // --------------------------------------------------------------------------- + @RequirePOST + public void doSteal(StaplerRequest2 req, StaplerResponse2 rsp) throws IOException, ServletException { + Jenkins.get().checkPermission(STEAL); + + List resources = this.getResourcesFromRequest(req, rsp); + if (resources == null) { + return; + } + + String reason = Util.fixEmptyAndTrim(req.getParameter("reason")); + + String userName = getUserName(); + if (userName == null) { + rsp.sendError(401, Messages.error_notAuthenticated()); + return; + } + + LockableResourcesManager.get().steal(resources, userName, reason); + rsp.forwardToPreviousPage(req); + } + + // --------------------------------------------------------------------------- + @RequirePOST + public void doReassign(StaplerRequest2 req, StaplerResponse2 rsp) throws IOException, ServletException { + Jenkins.get().checkPermission(STEAL); + + String userName = getUserName(); + if (userName == null) { + // defensive: this can not happens because we check you permissions few lines before + // therefore you must be logged in + throw new AccessDeniedException3(Jenkins.getAuthentication2(), STEAL); + } + + List resources = this.getResourcesFromRequest(req, rsp); + if (resources == null) { + return; + } + + for (LockableResource resource : resources) { + if (userName.equals(resource.getReservedBy())) { + // Can not achieve much by re-assigning the + // resource I already hold to myself again, + // that would just burn the compute resources. + // ...unless something catches the event? (TODO?) + return; + } + } + + LockableResourcesManager.get().reassign(resources, userName); + + rsp.forwardToPreviousPage(req); + } + + // --------------------------------------------------------------------------- + @RequirePOST + public void doUnreserve(StaplerRequest2 req, StaplerResponse2 rsp) throws IOException, ServletException { + Jenkins.get().checkPermission(RESERVE); + + List resources = this.getResourcesFromRequest(req, rsp); + if (resources == null) { + return; + } + + String userName = getUserName(); + for (LockableResource resource : resources) { + if ((userName == null || !userName.equals(resource.getReservedBy())) + && !Jenkins.get().hasPermission(Jenkins.ADMINISTER)) { + throw new AccessDeniedException3(Jenkins.getAuthentication2(), RESERVE); + } + } + + LockableResourcesManager.get().unreserve(resources); + + rsp.forwardToPreviousPage(req); + } + + // --------------------------------------------------------------------------- + @RequirePOST + public void doReset(StaplerRequest2 req, StaplerResponse2 rsp) throws IOException, ServletException { + Jenkins.get().checkPermission(UNLOCK); + // Should this also be permitted by "STEAL"?.. + + List resources = this.getResourcesFromRequest(req, rsp); + if (resources == null) { + return; + } + + LockableResourcesManager.get().reset(resources); + + rsp.forwardToPreviousPage(req); + } + + // --------------------------------------------------------------------------- + @RequirePOST + public void doRecycleDeadLocks(StaplerRequest2 req, StaplerResponse2 rsp) throws IOException, ServletException { + Jenkins.get().checkPermission(Jenkins.ADMINISTER); + FreeDeadJobs.freePostMortemResources(); + rsp.forwardToPreviousPage(req); + } + + // --------------------------------------------------------------------------- + @RequirePOST + public void doSaveNote(final StaplerRequest2 req, final StaplerResponse2 rsp) throws IOException, ServletException { + Jenkins.get().checkPermission(RESERVE); + + String resourceName = req.getParameter("resource"); + if (resourceName == null) { + resourceName = req.getParameter("resourceName"); + } + + final LockableResource resource = getResource(resourceName); + if (resource == null) { + rsp.sendError(404, Messages.error_resourceDoesNotExist(resourceName)); + } else { + String resourceNote = req.getParameter("note"); + if (resourceNote == null) { + resourceNote = req.getParameter("resourceNote"); + } + resource.setNote(resourceNote); + LockableResourcesManager.get().save(); + + rsp.forwardToPreviousPage(req); + } + } + + // --------------------------------------------------------------------------- + /** Returns resource names matching a Jenkins label expression as JSON. */ + @Restricted(NoExternalUse.class) + @GET + public void doGetResourcesByLabelExpression(final StaplerRequest2 req, final StaplerResponse2 rsp) + throws IOException { + Jenkins.get().checkPermission(VIEW); + + String expr = Util.fixEmptyAndTrim(req.getParameter("expr")); + + final List resources; + try { + resources = (expr == null) + ? LockableResourcesManager.get().getReadOnlyResources() + : LockableResourcesManager.get().getResourcesWithLabel(expr); + } catch (IllegalArgumentException e) { + rsp.sendError(400, "Invalid label expression: " + e.getMessage()); + return; + } + + net.sf.json.JSONArray items = new net.sf.json.JSONArray(); + for (LockableResource r : resources) { + if (r == null || r.getName().isEmpty()) { + continue; + } + net.sf.json.JSONObject obj = new net.sf.json.JSONObject(); + obj.put("name", r.getName()); + items.add(obj); + } + + net.sf.json.JSONObject result = new net.sf.json.JSONObject(); + result.put("expr", expr == null ? "" : expr); + result.put("total", items.size()); + result.put("items", items); + + rsp.setContentType("application/json;charset=UTF-8"); + rsp.getWriter().write(result.toString()); + } + + // --------------------------------------------------------------------------- + /** Change queue order (item position) */ + @Restricted(NoExternalUse.class) // used by jelly + @RequirePOST + public void doChangeQueueOrder(final StaplerRequest2 req, final StaplerResponse2 rsp) + throws IOException, ServletException { + Jenkins.get().checkPermission(QUEUE); + + final String queueId = req.getParameter("id"); + final String newIndexStr = req.getParameter("index"); + + LOGGER.fine("doChangeQueueOrder, id: " + queueId + " newIndexStr: " + newIndexStr); + + final int newIndex; + try { + newIndex = Integer.parseInt(newIndexStr); + } catch (NumberFormatException e) { + rsp.sendError(423, Messages.error_isNotANumber(newIndexStr)); + return; + } + + try { + LockableResourcesManager.get().changeQueueOrder(queueId, newIndex - 1); + } catch (IOException e) { + rsp.sendError(423, e.toString().replace("java.io.IOException: ", "")); + return; + } + + rsp.forwardToPreviousPage(req); + } + + // --------------------------------------------------------------------------- + /** Returns a page of queue items as JSON for server-side pagination. */ + @Restricted(NoExternalUse.class) + @RequirePOST + public void doGetQueuePage(final StaplerRequest2 req, final StaplerResponse2 rsp) + throws IOException, ServletException { + Jenkins.get().checkPermission(VIEW); + + int page = 1; + int size = 25; + String filter = Util.fixEmptyAndTrim(req.getParameter("filter")); + String typeFilter = Util.fixEmptyAndTrim(req.getParameter("type")); + String requestFilter = Util.fixEmptyAndTrim(req.getParameter("request")); + String requestedByFilter = Util.fixEmptyAndTrim(req.getParameter("requestedBy")); + + try { + String pageParam = req.getParameter("page"); + if (pageParam != null) page = Math.max(1, Integer.parseInt(pageParam)); + } catch (NumberFormatException ignored) { + } + try { + String sizeParam = req.getParameter("size"); + if (sizeParam != null) size = Math.max(1, Math.min(200, Integer.parseInt(sizeParam))); + } catch (NumberFormatException ignored) { + } + + Queue queue; + try { + queue = getQueue(); + } catch (Descriptor.FormException e) { + rsp.sendError(500, e.getMessage()); + return; + } + + List allItems = queue.getAll(); + + // Apply filter if provided + if (filter != null || typeFilter != null || requestFilter != null || requestedByFilter != null) { + final String lowerFilter = filter != null ? filter.toLowerCase(Locale.ENGLISH) : null; + final String lowerTypeFilter = typeFilter != null ? typeFilter.toLowerCase(Locale.ENGLISH) : null; + final String lowerRequestFilter = requestFilter != null ? requestFilter.toLowerCase(Locale.ENGLISH) : null; + final String lowerRequestedByFilter = + requestedByFilter != null ? requestedByFilter.toLowerCase(Locale.ENGLISH) : null; + allItems = allItems.stream() + .filter(item -> { + if (lowerTypeFilter != null + && !getQueueItemType(item) + .toLowerCase(Locale.ENGLISH) + .contains(lowerTypeFilter)) { + return false; + } + if (lowerRequestFilter != null + && !getQueueItemRequestText(item) + .toLowerCase(Locale.ENGLISH) + .contains(lowerRequestFilter)) { + return false; + } + if (lowerRequestedByFilter != null) { + String requestedByText = item.getRequestedBy(); + if (!requestedByText.toLowerCase(Locale.ENGLISH).contains(lowerRequestedByFilter)) { + return false; + } + } + + if (lowerFilter == null) { + return true; + } + + if (item.resourcesMatch()) { + for (LockableResource r : item.getRequiredResources()) { + if (r.getName().toLowerCase(Locale.ENGLISH).contains(lowerFilter)) return true; + } + } + if (item.labelsMatch() + && item.getRequiredLabel() + .toLowerCase(Locale.ENGLISH) + .contains(lowerFilter)) { + return true; + } + Run b = item.getBuild(); + if (b != null + && b.getFullDisplayName() + .toLowerCase(Locale.ENGLISH) + .contains(lowerFilter)) { + return true; + } + return item.getId().toLowerCase(Locale.ENGLISH).contains(lowerFilter); + }) + .collect(Collectors.toList()); + } + + int total = allItems.size(); + int pages = (int) Math.ceil((double) total / size); + int fromIndex = Math.min((page - 1) * size, total); + int toIndex = Math.min(fromIndex + size, total); + List pageItems = allItems.subList(fromIndex, toIndex); + + // Build JSON response + net.sf.json.JSONObject result = new net.sf.json.JSONObject(); + result.put("page", page); + result.put("size", size); + result.put("total", total); + result.put("pages", pages); + + net.sf.json.JSONArray items = new net.sf.json.JSONArray(); + int index = fromIndex; + for (Queue.QueueStruct item : pageItems) { + net.sf.json.JSONObject obj = new net.sf.json.JSONObject(); + obj.put("index", index + 1); + obj.put("id", item.getId()); + obj.put("priority", item.getPriority()); + obj.put("queuedAt", item.getQueuedAt()); + obj.put("requestText", getQueueItemRequestText(item)); + obj.put( + "queuedAtHuman", + item.getQueuedAt() > 0 + ? Util.getTimeSpanString(System.currentTimeMillis() - item.getQueuedAt()) + : ""); + + obj.put("requestedBy", item.getRequestedBy()); + obj.put("requestedByUrl", item.getRequestedByUrl()); + + if (item.isRemote()) { + obj.put("type", "remote"); + obj.put("request", item.getRemoteRequest()); + obj.put("reason", item.getRemoteReason()); + } else if (item.resourcesMatch()) { + obj.put("type", "resources"); + net.sf.json.JSONArray resources = new net.sf.json.JSONArray(); + for (LockableResource r : item.getRequiredResources()) { + net.sf.json.JSONObject ro = new net.sf.json.JSONObject(); + ro.put("name", r.getName()); + ro.put("ephemeral", r.isEphemeral()); + ro.put("description", r.getDescription() != null ? r.getDescription() : ""); + resources.add(ro); + } + obj.put("request", resources); + obj.put("reason", ""); + } else if (item.labelsMatch()) { + obj.put("type", "label"); + obj.put("request", item.getRequiredLabel()); + String requiredNum = item.getRequiredNumber(); + obj.put("reason", "0".equals(requiredNum) ? "all" : requiredNum + " required"); + } else { + obj.put("type", "groovy"); + obj.put("request", "Groovy expression"); + obj.put("reason", ""); + } + + items.add(obj); + index++; + } + result.put("items", items); + + // Warning info + Queue.QueueStruct oldest = queue.getOldest(); + if (oldest != null && oldest.takeTooLong()) { + result.put("warningCount", allItems.size()); + result.put("warningAge", Util.getTimeSpanString(System.currentTimeMillis() - oldest.getQueuedAt())); + } + + rsp.setContentType("application/json;charset=UTF-8"); + rsp.getWriter().write(result.toString()); + } + + private String getQueueItemType(final Queue.QueueStruct item) { + if (item.isRemote()) { + return "remote"; + } + if (item.resourcesMatch()) { + return "resources"; + } + if (item.labelsMatch()) { + return "label"; + } + return "groovy"; + } + + private String getQueueItemRequestText(final Queue.QueueStruct item) { + if (item.isRemote()) { + return item.getRemoteRequest(); + } + if (item.resourcesMatch()) { + return item.getRequiredResources().stream() + .map(LockableResource::getName) + .collect(Collectors.joining(", ")); + } + if (item.labelsMatch()) { + return item.getRequiredLabel(); + } + return "Groovy expression"; + } + + // --------------------------------------------------------------------------- + /** Parse properties from a JSON array. */ + private List parsePropertiesFromJson(net.sf.json.JSONObject json) { + List properties = new ArrayList<>(); + net.sf.json.JSONArray propsArr = json.optJSONArray("properties"); + if (propsArr != null) { + for (int i = 0; i < propsArr.size(); i++) { + net.sf.json.JSONObject p = propsArr.getJSONObject(i); + String pName = Util.fixEmptyAndTrim(p.optString("name", null)); + String pValue = p.optString("value", ""); + if (pName != null) { + LockableResourceProperty prop = new LockableResourceProperty(); + prop.setName(pName); + prop.setValue(pValue); + properties.add(prop); + } + } + } + return properties; + } + + // --------------------------------------------------------------------------- + /** Create a new lockable resource from the management page. */ + @Restricted(NoExternalUse.class) + @RequirePOST + public void doCreateResource(final StaplerRequest2 req, final StaplerResponse2 rsp) + throws IOException, ServletException { + Jenkins.get().checkPermission(CONFIGURE); + + String name; + String description; + String labels; + List properties = new ArrayList<>(); + + String contentType = req.getContentType(); + if (contentType != null && contentType.contains("application/json")) { + // JSON body from the inline dialog + net.sf.json.JSONObject json = net.sf.json.JSONObject.fromObject( + new String(req.getInputStream().readAllBytes(), java.nio.charset.StandardCharsets.UTF_8)); + name = Util.fixEmptyAndTrim(json.optString("name", null)); + description = Util.fixEmptyAndTrim(json.optString("description", null)); + labels = Util.fixEmptyAndTrim(json.optString("labels", null)); + properties = parsePropertiesFromJson(json); + } else { + // Form-encoded (e.g. from tests) + name = Util.fixEmptyAndTrim(req.getParameter("name")); + description = Util.fixEmptyAndTrim(req.getParameter("description")); + labels = Util.fixEmptyAndTrim(req.getParameter("labels")); + } + + if (name == null) { + rsp.sendError(400, "Resource name is required."); + return; + } + + LockableResourcesManager manager = LockableResourcesManager.get(); + if (manager.fromName(name) != null) { + rsp.sendError(409, Messages.error_resourceAlreadyExists(name)); + return; + } + + LockableResource resource = new LockableResource(name); + + if (description != null) { + resource.setDescription(description); + } + if (labels != null) { + resource.setLabelsFromString(labels); + } + if (!properties.isEmpty()) { + resource.setProperties(properties); + } + + boolean added = manager.addResource(resource, /*doSave*/ true); + if (!added) { + rsp.sendError(409, Messages.error_resourceAlreadyExists(name)); + return; + } + + rsp.forwardToPreviousPage(req); + } + + // --------------------------------------------------------------------------- + /** Edit an existing lockable resource from the management page. */ + @Restricted(NoExternalUse.class) + @RequirePOST + public void doEditResource(final StaplerRequest2 req, final StaplerResponse2 rsp) + throws IOException, ServletException { + Jenkins.get().checkPermission(CONFIGURE); + + String contentType = req.getContentType(); + if (contentType == null || !contentType.contains("application/json")) { + rsp.sendError(400, "JSON body required."); + return; + } + + net.sf.json.JSONObject json = net.sf.json.JSONObject.fromObject( + new String(req.getInputStream().readAllBytes(), java.nio.charset.StandardCharsets.UTF_8)); + + String name = Util.fixEmptyAndTrim(json.optString("name", null)); + if (name == null) { + rsp.sendError(400, "Resource name is required."); + return; + } + + LockableResourcesManager manager = LockableResourcesManager.get(); + synchronized (LockableResourcesManager.syncResources) { + LockableResource resource = manager.fromName(name); + if (resource == null) { + rsp.sendError(404, Messages.error_resourceDoesNotExist(name)); + return; + } + + resource.setDescription(Util.fixEmptyAndTrim(json.optString("description", null))); + resource.setLabelsFromString(Util.fixEmptyAndTrim(json.optString("labels", null))); + resource.setProperties(parsePropertiesFromJson(json)); + + manager.save(); + } + rsp.forwardToPreviousPage(req); + } + + // --------------------------------------------------------------------------- + /** Delete a lockable resource from the management page. */ + @Restricted(NoExternalUse.class) + @RequirePOST + public void doDeleteResource(final StaplerRequest2 req, final StaplerResponse2 rsp) + throws IOException, ServletException { + Jenkins.get().checkPermission(CONFIGURE); + + String name = req.getParameter("resource"); + LockableResourcesManager manager = LockableResourcesManager.get(); + synchronized (LockableResourcesManager.syncResources) { + LockableResource resource = manager.fromName(name); + if (resource == null) { + rsp.sendError(404, Messages.error_resourceDoesNotExist(name)); + return; + } + + if (!resource.isFree()) { + String cause = resource.isLocked() ? "locked" : resource.isReserved() ? "reserved" : "queued"; + rsp.sendError(423, Messages.error_resourceAlreadyLocked(name) + " (" + cause + ")"); + return; + } + + List toRemove = new ArrayList<>(); + toRemove.add(resource); + manager.removeResources(toRemove); + manager.save(); + } + rsp.forwardToPreviousPage(req); + } + + // --------------------------------------------------------------------------- + private List getResourcesFromRequest(final StaplerRequest2 req, final StaplerResponse2 rsp) + throws IOException, ServletException { + // todo, when you try to improve the API to use multiple resources (a list instead of single + // one) + // this will be the best place to change it. Probably it will be enough to add a code piece here + // like req.getParameter("resources"); And split the content by some delimiter like ' ' (space) + String name = req.getParameter("resource"); + LockableResource r = LockableResourcesManager.get().fromName(name); + if (r == null) { + rsp.sendError(404, Messages.error_resourceDoesNotExist(name)); + return null; + } + + List resources = new ArrayList<>(); + resources.add(r); + return resources; + } + + // --------------------------------------------------------------------------- + /** + * Routes {@code /lockable-resources/remote/*} to the remote lock API. + */ + public Object getDynamic(String token) { + if ("remote".equals(token)) { + return new RemoteRouterAction(); + } + return null; + } + + /** Routes {@code /lockable-resources/remote/v1/*} to {@link RemoteApiV1Action}. */ + @Restricted(NoExternalUse.class) + public static final class RemoteRouterAction { + public Object getDynamic(String token) { + if ("v1".equals(token)) { + return new RemoteApiV1Action(); + } + return null; + } + } } diff --git a/src/main/java/org/jenkins/plugins/lockableresources/actions/LockedResourcesBuildAction.java b/src/main/java/org/jenkins/plugins/lockableresources/actions/LockedResourcesBuildAction.java index 8d7a7d322..0d0d0189c 100644 --- a/src/main/java/org/jenkins/plugins/lockableresources/actions/LockedResourcesBuildAction.java +++ b/src/main/java/org/jenkins/plugins/lockableresources/actions/LockedResourcesBuildAction.java @@ -1,76 +1,216 @@ -/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * - * Copyright (c) 2013, 6WIND S.A. All rights reserved. * - * * - * This file is part of the Jenkins Lockable Resources Plugin and is * - * published under the MIT license. * - * * - * See the "LICENSE.txt" file for more information. * - * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ package org.jenkins.plugins.lockableresources.actions; import hudson.model.Action; - +import hudson.model.Run; import java.util.ArrayList; -import java.util.Collection; +import java.util.Collections; +import java.util.Date; import java.util.List; - -import org.jenkins.plugins.lockableresources.LockableResource; - +import net.jcip.annotations.GuardedBy; +import org.jenkins.plugins.lockableresources.Messages; +import org.kohsuke.accmod.Restricted; +import org.kohsuke.accmod.restrictions.NoExternalUse; + +// ----------------------------------------------------------------------------- +/** BuildAction for lockable resources. + * Shows usage of resources in the build page. + * url: jobUrl/buildNr/locked-resources/ + */ +@Restricted(NoExternalUse.class) public class LockedResourcesBuildAction implements Action { - private final List lockedResources; - - public LockedResourcesBuildAction(List lockedResources) { - this.lockedResources = lockedResources; - } - - public List getLockedResources() { - return lockedResources; - } - - public String getIconFileName() { - return LockableResourcesRootAction.ICON; - } - - public String getDisplayName() { - return "Locked Resources"; - } - - public String getUrlName() { - return "locked-resources"; - } - - public static LockedResourcesBuildAction fromResources( - Collection resources) { - List resPojos = new ArrayList(); - for (LockableResource r : resources) - resPojos.add(new ResourcePOJO(r)); - return new LockedResourcesBuildAction(resPojos); - } - - public static class ResourcePOJO { - - private String name; - private String description; - - public ResourcePOJO(String name, String description) { - this.name = name; - this.description = description; - } - - public ResourcePOJO(LockableResource r) { - this.name = r.getName(); - this.description = r.getDescription(); - } - - public String getName() { - return name; - } - - public String getDescription() { - return description; - } - - } - + @GuardedBy("logs") + private List logs; + + @GuardedBy("resourcesInUse") + private List resourcesInUse; + + public LockedResourcesBuildAction() { + this.logs = new ArrayList<>(); + this.resourcesInUse = new ArrayList<>(); + } + + // ------------------------------------------------------------------------- + @Override + public String getIconFileName() { + return LockableResourcesRootAction.ICON; + } + + // ------------------------------------------------------------------------- + @Override + public String getDisplayName() { + return Messages.LockedResourcesBuildAction_displayName(); + } + + // ------------------------------------------------------------------------- + @Override + public String getUrlName() { + return "locked-resources"; + } + + public List getCurrentUsedResourceNames() { + synchronized (this) { + if (resourcesInUse == null) { + resourcesInUse = new ArrayList<>(); + } + return resourcesInUse; + } + } + + public void addUsedResources(List resourceNames) { + ensureListsInitialized(); + synchronized (this.resourcesInUse) { + resourcesInUse.addAll(resourceNames); + } + } + + public void removeUsedResources(List resourceNames) { + ensureListsInitialized(); + synchronized (this.resourcesInUse) { + resourcesInUse.removeAll(resourceNames); + } + } + + public static LockedResourcesBuildAction findAndInitAction(final Run build) { + if (build == null) { + return null; + } + LockedResourcesBuildAction action; + final Object lock = build.getId(); + // It is very difficult to guarantee correct operation when synchronizing on a parameter. + // There is no control over the identity, visibility, or lifecycle of that object. + synchronized (lock) { + List actions = build.getActions(LockedResourcesBuildAction.class); + + if (actions.isEmpty()) { + action = new LockedResourcesBuildAction(); + build.addAction(action); + } else { + action = actions.get(0); + } + } + return action; + } + + public static void addLog( + final Run build, final List resourceNames, final String step, final String action) { + + for (String resourceName : resourceNames) addLog(build, resourceName, step, action); + } + + public static void addLog( + final Run build, final String resourceName, final String step, final String action) { + + LockedResourcesBuildAction buildAction = findAndInitAction(build); + + buildAction.addLog(resourceName, step, action); + } + + public void addLog(final String resourceName, final String step, final String action) { + ensureListsInitialized(); + synchronized (this.logs) { + this.logs.add(new LogEntry(step, action, resourceName)); + } + } + + @Restricted(NoExternalUse.class) + public List getReadOnlyLogs() { + ensureListsInitialized(); + synchronized (this.logs) { + return new ArrayList<>(Collections.unmodifiableCollection(this.logs)); + } + } + + @Restricted(NoExternalUse.class) + public List getReadOnlyResourcesInUse() { + ensureListsInitialized(); + synchronized (this.resourcesInUse) { + return new ArrayList<>(Collections.unmodifiableCollection(this.resourcesInUse)); + } + } + + /** Copy constructor, primarily for {@link #writeReplace} */ + private LockedResourcesBuildAction(LockedResourcesBuildAction other) { + this(); + if (other == null) { + return; + } + + // Defensive for old/corrupted deserialized state where fields may be null. + synchronized (other) { + if (other.logs != null) { + synchronized (other.logs) { + this.logs.addAll(other.logs); + } + } + if (other.resourcesInUse != null) { + synchronized (other.resourcesInUse) { + this.resourcesInUse.addAll(other.resourcesInUse); + } + } + } + } + + private synchronized void ensureListsInitialized() { + if (logs == null) { + logs = new ArrayList<>(); + } + if (resourcesInUse == null) { + resourcesInUse = new ArrayList<>(); + } + } + /** + * Ensure iteration during XStream marshalling is also synchronized, + * otherwise we tend to get {@link java.util.ConcurrentModificationException}.
+ * + * The recommended approach is to copy-on-write the properties so a + * snapshot can always be scraped consistently. But this can be costly + * at run-time, so we use the next-best option: produce a consistent + * replica of the current object for actual saving only on demand.
+ * + * This method is found by XStream via reflection.
+ */ + protected synchronized Object writeReplace() { + return new LockedResourcesBuildAction(this); + } + + public static class LogEntry { + + private final String step; + private final String action; + private final String resourceName; + private final long timeStamp; + + @Restricted(NoExternalUse.class) + public LogEntry(final String step, final String action, final String resourceName) { + this.step = step; + this.action = action; + this.resourceName = resourceName; + this.timeStamp = new Date().getTime(); + } + + // --------------------------------------------------------------------- + @Restricted(NoExternalUse.class) + public String getName() { + return this.resourceName; + } + + // --------------------------------------------------------------------- + @Restricted(NoExternalUse.class) + public String getStep() { + return this.step; + } + + // --------------------------------------------------------------------- + @Restricted(NoExternalUse.class) + public String getAction() { + return this.action; + } + + // --------------------------------------------------------------------- + @Restricted(NoExternalUse.class) + public Date getTimeStamp() { + return new Date(this.timeStamp); + } + } } diff --git a/src/main/java/org/jenkins/plugins/lockableresources/actions/RemoteApiV1Action.java b/src/main/java/org/jenkins/plugins/lockableresources/actions/RemoteApiV1Action.java new file mode 100644 index 000000000..248e98eb7 --- /dev/null +++ b/src/main/java/org/jenkins/plugins/lockableresources/actions/RemoteApiV1Action.java @@ -0,0 +1,395 @@ +/* + * The MIT License + * + * See the "LICENSE.txt" file for full copyright and license information. + */ +package org.jenkins.plugins.lockableresources.actions; + +import java.io.BufferedReader; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; +import java.util.logging.Logger; +import jenkins.model.Jenkins; +import net.sf.json.JSONArray; +import net.sf.json.JSONObject; +import org.jenkins.plugins.lockableresources.LockableResourcesManager; +import org.jenkins.plugins.lockableresources.ResourceSelectStrategy; +import org.jenkins.plugins.lockableresources.remote.RemoteLockManager; +import org.jenkins.plugins.lockableresources.remote.RemoteLockRecord; +import org.jenkins.plugins.lockableresources.remote.RemoteLockRequest; +import org.jenkins.plugins.lockableresources.remote.RemoteLockState; +import org.kohsuke.accmod.Restricted; +import org.kohsuke.accmod.restrictions.NoExternalUse; +import org.kohsuke.stapler.StaplerRequest2; +import org.kohsuke.stapler.StaplerResponse2; +import org.kohsuke.stapler.interceptor.RequirePOST; + +/** + * Serves the remote lock REST API under {@code /lockable-resources/remote/v1/}. + * + *

Endpoints: + *

    + *
  • {@code POST /acquire} - enqueue an acquire request
  • + *
  • {@code GET /acquire/{lockId}} - poll acquire status
  • + *
  • {@code POST /lease/{lockId}/heartbeat} - renew lease
  • + *
  • {@code POST /lease/{lockId}/release} - release lock
  • + *
+ * + *

All endpoints require the dedicated {@link LockableResourcesRootAction#REMOTE} + * permission (implied by ADMINISTER). Grant it to the machine users whose API + * tokens remote client controllers use as {@code credentialsId}. + * If Remote API is disabled (see {@link LockableResourcesManager#isRemoteApiEnabled()}) + * every endpoint returns 403. + */ +@Restricted(NoExternalUse.class) +public class RemoteApiV1Action { + + private static final Logger LOGGER = Logger.getLogger(RemoteApiV1Action.class.getName()); + + // ----------------------------------------------------------------------- + // Dynamic routing for /acquire/{lockId} and /lease/{lockId}/* + // ----------------------------------------------------------------------- + + public Object getDynamic(String token) { + switch (token) { + case "acquire": + return new AcquireRouter(); + case "lease": + return new LeaseRouter(); + default: + return null; + } + } + + // ----------------------------------------------------------------------- + // Routes POST /acquire and GET /acquire/{lockId} + // ----------------------------------------------------------------------- + + public static final class AcquireRouter { + @RequirePOST + public void doIndex(StaplerRequest2 req, StaplerResponse2 rsp) throws IOException { + Jenkins.get().checkPermission(LockableResourcesRootAction.REMOTE); + + LockableResourcesManager lrm = LockableResourcesManager.get(); + if (!lrm.isRemoteApiEnabled()) { + sendJsonError(rsp, 403, "REMOTE_API_DISABLED", "Remote API is not enabled on this server"); + return; + } + + JSONObject body; + try { + body = parseJsonBody(req); + } catch (PayloadTooLargeException e) { + // Bound the request body so an authenticated client cannot OOM the server. + sendJsonError( + rsp, + 413, + "PAYLOAD_TOO_LARGE", + "Request body exceeds the maximum allowed size of " + MAX_BODY_CHARS + " characters"); + return; + } catch (Exception e) { + sendJsonError(rsp, 400, "INVALID_JSON", "Request body must be valid JSON"); + return; + } + + JSONObject lockRequestJson = body.optJSONObject("lockRequest"); + if (lockRequestJson == null) { + sendJsonError(rsp, 400, "MISSING_LOCK_REQUEST", "Field 'lockRequest' is required"); + return; + } + + String resource = lockRequestJson.optString("resource", null); + if (resource != null) resource = resource.trim(); + if (resource != null && resource.isEmpty()) resource = null; + + String label = lockRequestJson.optString("label", null); + if (label != null) label = label.trim(); + if (label != null && label.isEmpty()) label = null; + + // extra-only requests are valid (local lock() allows them when extra is present), + // so only reject when there is no main target AND no extra. + JSONArray extraPeek = lockRequestJson.optJSONArray("extra"); + boolean hasExtra = extraPeek != null && !extraPeek.isEmpty(); + if (resource == null && label == null && !hasExtra) { + sendJsonError( + rsp, 400, "MISSING_TARGET", "lockRequest must contain at least one of: resource, label, extra"); + return; + } + + // Exposure/existence is enforced by the admission check inside enqueue (validateRemoteSelectors): + // a selector referencing something this client can't lock (unknown/unexposed) comes back + // as a terminal UNKNOWN_* record, which we map to HTTP 404 below. This endpoint only parses the + // request; "all matching visible" resolution stays on the canonical lock() path. + + boolean skipIfLocked = lockRequestJson.optBoolean("skipIfLocked", false); + // quantity 0 (or absent) means "all matching" for label requests, matching local lock() + // (LockableResourcesManager "0 means all"); must NOT default to 1. + int quantity = lockRequestJson.optInt("quantity", 0); + String variable = lockRequestJson.optString("variable", null); + if (variable != null && variable.isEmpty()) variable = null; + boolean inversePrecedence = lockRequestJson.optBoolean("inversePrecedence", false); + String resourceSelectStrategy = lockRequestJson.optString("resourceSelectStrategy", "SEQUENTIAL"); + try { + ResourceSelectStrategy.valueOf(resourceSelectStrategy.toUpperCase(Locale.ENGLISH)); + } catch (IllegalArgumentException e) { + sendJsonError( + rsp, + 400, + "INVALID_SELECT_STRATEGY", + "resourceSelectStrategy must be one of " + Arrays.toString(ResourceSelectStrategy.values())); + return; + } + int priority = lockRequestJson.optInt("priority", 0); + long timeoutForAllocateResource = lockRequestJson.optLong("timeoutForAllocateResource", 0); + String timeoutUnit = lockRequestJson.optString("timeoutUnit", "MINUTES"); + String reason = lockRequestJson.optString("reason", null); + if (reason != null && reason.isEmpty()) reason = null; + + // Parse extra resources (optional - additional resources to lock atomically) + List extra = null; + JSONArray extraArray = lockRequestJson.optJSONArray("extra"); + if (extraArray != null && !extraArray.isEmpty()) { + extra = new ArrayList<>(extraArray.size()); + for (int i = 0; i < extraArray.size(); i++) { + JSONObject extraEntry = extraArray.getJSONObject(i); + String extraResource = extraEntry.optString("resource", null); + if (extraResource != null) extraResource = extraResource.trim(); + if (extraResource != null && extraResource.isEmpty()) extraResource = null; + String extraLabel = extraEntry.optString("label", null); + if (extraLabel != null) extraLabel = extraLabel.trim(); + if (extraLabel != null && extraLabel.isEmpty()) extraLabel = null; + if (extraResource == null && extraLabel == null) { + sendJsonError( + rsp, + 400, + "INVALID_EXTRA", + "extra[" + i + "] must contain at least one of: resource, label"); + return; + } + // Exposure/existence of this extra selector is checked by admission in enqueue (see above). + int extraQuantity = extraEntry.optInt("quantity", 0); // 0/absent = all (label) + extra.add(new RemoteLockRequest.ExtraResource(extraResource, extraLabel, extraQuantity)); + } + } + + // clientId is optional - identifies the calling Jenkins instance (e.g. root URL) + String clientId = body.optString("clientId", null); + if (clientId != null) { + clientId = clientId.trim(); + if (clientId.isEmpty()) { + clientId = null; + } + } + + // heartbeatIntervalSeconds is optional but must be a positive integer when present + if (body.containsKey("heartbeatIntervalSeconds")) { + int hbi; + try { + hbi = body.getInt("heartbeatIntervalSeconds"); + } catch (Exception e) { + sendJsonError( + rsp, + 400, + "INVALID_HEARTBEAT_INTERVAL", + "heartbeatIntervalSeconds must be a positive integer"); + return; + } + if (hbi <= 0) { + sendJsonError( + rsp, 400, "INVALID_HEARTBEAT_INTERVAL", "heartbeatIntervalSeconds must be greater than 0"); + return; + } + // issue #1025 phase 1: the server uses its own heartbeat/STALE constant; a valid + // client-supplied heartbeatIntervalSeconds is accepted but ignored (per-request + // configurability is out of phase 1 scope). + } + + RemoteLockRequest lockRequest = new RemoteLockRequest( + resource, + label, + quantity, + variable, + inversePrecedence, + resourceSelectStrategy, + skipIfLocked, + extra, + priority, + timeoutForAllocateResource, + timeoutUnit, + reason); + + RemoteLockRecord record = RemoteLockManager.get().enqueue(lockRequest, clientId); + String logTarget = resource != null ? resource : "label:" + label; + LOGGER.fine("POST /acquire target=" + logTarget + " lockId=" + record.getLockId() + " clientId=" + + record.getClientId() + " state=" + record.getState()); + + // Admission rejected the request - nothing this client can lock (unknown/unexposed). + // Uniform 404 (errorCode distinguishes resource vs label); existence is not otherwise revealed. + // Any other terminal FAILED from enqueue must map to a 4xx, never fall through to a + // 202 success (defensive - MISSING_TARGET is already rejected at the boundary above). + if (record.getState() == RemoteLockState.FAILED) { + String ec = record.getErrorCode(); + if ("UNKNOWN_RESOURCE".equals(ec) || "UNKNOWN_LABEL".equals(ec)) { + sendJsonError(rsp, 404, ec, "No lockable resource matches the request"); + } else { + sendJsonError(rsp, 400, ec != null ? ec : "ACQUIRE_FAILED", "Remote acquire request was rejected"); + } + return; + } + + JSONObject response = new JSONObject(); + response.put("lockId", record.getLockId()); + response.put("state", record.getState().name()); + + rsp.setStatus(202); + rsp.setContentType("application/json;charset=UTF-8"); + rsp.getWriter().write(response.toString()); + } + + public Object getDynamic(String lockId) { + return new AcquireStatusResource(lockId); + } + } + + /** Serves {@code GET /acquire/{lockId}}. */ + public static final class AcquireStatusResource { + + private final String lockId; + + AcquireStatusResource(String lockId) { + this.lockId = lockId; + } + + public void doIndex(StaplerRequest2 req, StaplerResponse2 rsp) throws IOException { + Jenkins.get().checkPermission(LockableResourcesRootAction.REMOTE); + + if (!LockableResourcesManager.get().isRemoteApiEnabled()) { + sendJsonError(rsp, 403, "REMOTE_API_DISABLED", "Remote API is not enabled"); + return; + } + + // Pure read: GET /acquire/{lockId} only observes state. QUEUED lifetime is owned entirely by + // the server-side unified queue (promotion via proceedNextContext, expiry via the + // RemoteQueueEntry timeout) - polling does not drive or keep alive the queue entry. + RemoteLockRecord record = RemoteLockManager.get().find(lockId); + if (record == null) { + sendJsonError(rsp, 404, "LOCK_NOT_FOUND", "Lock not found: " + lockId); + return; + } + + JSONObject response = new JSONObject(); + response.put("lockId", record.getLockId()); + response.put("state", record.getState().name()); + if (record.getErrorCode() != null) { + response.put("errorCode", record.getErrorCode()); + } + if (record.getLockEnvVars() != null) { + JSONObject envVarsJson = new JSONObject(); + envVarsJson.putAll(record.getLockEnvVars()); + response.put("lockEnvVars", envVarsJson); + } + + rsp.setStatus(200); + rsp.setContentType("application/json;charset=UTF-8"); + rsp.getWriter().write(response.toString()); + } + } + + // ----------------------------------------------------------------------- + // Routes /lease/{lockId}/heartbeat and /lease/{lockId}/release + // ----------------------------------------------------------------------- + + public static final class LeaseRouter { + public Object getDynamic(String lockId) { + return new LeaseResource(lockId); + } + } + + /** Serves {@code POST /lease/{lockId}/heartbeat} and {@code POST /lease/{lockId}/release}. */ + public static final class LeaseResource { + + private final String lockId; + + LeaseResource(String lockId) { + this.lockId = lockId; + } + + @RequirePOST + public void doHeartbeat(StaplerRequest2 req, StaplerResponse2 rsp) throws IOException { + Jenkins.get().checkPermission(LockableResourcesRootAction.REMOTE); + + if (!LockableResourcesManager.get().isRemoteApiEnabled()) { + sendJsonError(rsp, 403, "REMOTE_API_DISABLED", "Remote API is not enabled"); + return; + } + + boolean ok = RemoteLockManager.get().heartbeat(lockId); + if (!ok) { + // Record gone, not ACQUIRED (STALE/QUEUED), or never existed + RemoteLockRecord record = RemoteLockManager.get().find(lockId); + if (record != null && record.getState() == RemoteLockState.STALE) { + sendJsonError(rsp, 410, "LOCK_STALE", "Lock is STALE; contact administrator: " + lockId); + } else { + sendJsonError(rsp, 410, "LOCK_NOT_FOUND", "Lock not found or not active: " + lockId); + } + return; + } + + rsp.setStatus(204); + } + + @RequirePOST + public void doRelease(StaplerRequest2 req, StaplerResponse2 rsp) throws IOException { + Jenkins.get().checkPermission(LockableResourcesRootAction.REMOTE); + + if (!LockableResourcesManager.get().isRemoteApiEnabled()) { + sendJsonError(rsp, 403, "REMOTE_API_DISABLED", "Remote API is not enabled"); + return; + } + + RemoteLockManager.get().release(lockId); // idempotent + rsp.setStatus(204); + } + } + + // ----------------------------------------------------------------------- + // Helpers + // ----------------------------------------------------------------------- + + /** Cap on the POST body size (in characters) to avoid unbounded reads. */ + static final int MAX_BODY_CHARS = 1024 * 1024; // 1 MiB + + /** Signals that the request body exceeded {@link #MAX_BODY_CHARS}; mapped to HTTP 413. */ + private static final class PayloadTooLargeException extends IOException { + private static final long serialVersionUID = 1L; + } + + private static JSONObject parseJsonBody(StaplerRequest2 req) throws IOException { + StringBuilder sb = new StringBuilder(); + try (BufferedReader reader = req.getReader()) { + char[] buf = new char[1024]; + int n; + int total = 0; + while ((n = reader.read(buf)) != -1) { + total += n; + if (total > MAX_BODY_CHARS) { + throw new PayloadTooLargeException(); + } + sb.append(buf, 0, n); + } + } + return JSONObject.fromObject(sb.toString()); + } + + static void sendJsonError(StaplerResponse2 rsp, int status, String code, String message) throws IOException { + JSONObject err = new JSONObject(); + err.put("errorCode", code); + err.put("message", message); + rsp.setStatus(status); + rsp.setContentType("application/json;charset=UTF-8"); + rsp.getWriter().write(err.toString()); + } +} diff --git a/src/main/java/org/jenkins/plugins/lockableresources/actions/ResourceVariableNameAction.java b/src/main/java/org/jenkins/plugins/lockableresources/actions/ResourceVariableNameAction.java index 3595cd928..9658161aa 100644 --- a/src/main/java/org/jenkins/plugins/lockableresources/actions/ResourceVariableNameAction.java +++ b/src/main/java/org/jenkins/plugins/lockableresources/actions/ResourceVariableNameAction.java @@ -1,10 +1,6 @@ package org.jenkins.plugins.lockableresources.actions; -import java.io.IOException; - -import org.kohsuke.accmod.Restricted; -import org.kohsuke.accmod.restrictions.NoExternalUse; - +import edu.umd.cs.findbugs.annotations.NonNull; import hudson.EnvVars; import hudson.Extension; import hudson.model.EnvironmentContributor; @@ -12,32 +8,34 @@ import hudson.model.Run; import hudson.model.StringParameterValue; import hudson.model.TaskListener; +import java.util.List; +import org.kohsuke.accmod.Restricted; +import org.kohsuke.accmod.restrictions.NoExternalUse; @Restricted(NoExternalUse.class) public class ResourceVariableNameAction extends InvisibleAction { - private final StringParameterValue resourceNameParameter; - - public ResourceVariableNameAction(StringParameterValue r) { - this.resourceNameParameter = r; - } - - StringParameterValue getParameter() { - return resourceNameParameter; - } - - @Extension - public static final class ResourceVariableNameActionEnvironmentContributor extends EnvironmentContributor { - - @Override - public void buildEnvironmentFor(Run r, EnvVars envs, TaskListener listener) - throws IOException, InterruptedException { - ResourceVariableNameAction a = r.getAction(ResourceVariableNameAction.class); - if (a != null && a.getParameter() != null && a.getParameter().getValue() != null) { - envs.put(a.getParameter().getName(), String.valueOf(a.getParameter().getValue())); - } - } - - } - + private final List resourceNameParameter; + + public ResourceVariableNameAction(List r) { + this.resourceNameParameter = r; + } + + List getParameter() { + return resourceNameParameter; + } + + @Extension + public static final class ResourceVariableNameActionEnvironmentContributor extends EnvironmentContributor { + + @Override + public void buildEnvironmentFor(@NonNull Run r, @NonNull EnvVars envs, @NonNull TaskListener listener) { + ResourceVariableNameAction a = r.getAction(ResourceVariableNameAction.class); + if (a != null && a.getParameter() != null) { + for (StringParameterValue envToSet : a.getParameter()) { + envs.override(envToSet.getName(), envToSet.getValue()); + } + } + } + } } diff --git a/src/main/java/org/jenkins/plugins/lockableresources/nodes/NodesMirror.java b/src/main/java/org/jenkins/plugins/lockableresources/nodes/NodesMirror.java new file mode 100644 index 000000000..00de96c6f --- /dev/null +++ b/src/main/java/org/jenkins/plugins/lockableresources/nodes/NodesMirror.java @@ -0,0 +1,108 @@ +package org.jenkins.plugins.lockableresources.nodes; + +import hudson.Extension; +import hudson.init.InitMilestone; +import hudson.init.Initializer; +import hudson.model.Node; +import hudson.slaves.ComputerListener; +import java.util.Iterator; +import java.util.logging.Logger; +import java.util.stream.Collectors; +import jenkins.model.Jenkins; +import jenkins.util.SystemProperties; +import org.jenkins.plugins.lockableresources.LockableResource; +import org.jenkins.plugins.lockableresources.LockableResourcesManager; +import org.jenkins.plugins.lockableresources.util.Constants; + +// ----------------------------------------------------------------------------- +/** Mirror Jenkins nodes to lockable-resources */ +@Extension +public class NodesMirror extends ComputerListener { + + private static final Logger LOGGER = Logger.getLogger(NodesMirror.class.getName()); + private static LockableResourcesManager lrm; + + // --------------------------------------------------------------------------- + private static boolean isNodeMirrorEnabled() { + return SystemProperties.getBoolean(Constants.SYSTEM_PROPERTY_ENABLE_NODE_MIRROR); + } + + // --------------------------------------------------------------------------- + @Initializer(after = InitMilestone.JOB_LOADED) + public static void createNodeResources() { + LOGGER.info("lockable-resources-plugin: configure node resources"); + mirrorNodes(); + } + + // --------------------------------------------------------------------------- + @Override + public final void onConfigurationChange() { + mirrorNodes(); + } + + // --------------------------------------------------------------------------- + private static void mirrorNodes() { + if (!isNodeMirrorEnabled()) { + return; + } + + LOGGER.info("lockable-resources-plugin: start nodes mirroring"); + lrm = LockableResourcesManager.get(); + synchronized (LockableResourcesManager.syncResources) { + for (Node n : Jenkins.get().getNodes()) { + mirrorNode(n); + } + // please do not remove it, From time to time is necessary for developer debugs + // thx + // lrm.printResources(); + deleteNotExistingNodes(); + // lrm.printResources(); + } + LOGGER.info("lockable-resources-plugin: nodes mirroring finished"); + } + + // --------------------------------------------------------------------------- + private static void deleteNotExistingNodes() { + Iterator resourceIterator = lrm.getResources().iterator(); + while (resourceIterator.hasNext()) { + LockableResource resource = resourceIterator.next(); + if (!resource.isNodeResource() || (Jenkins.get().getNode(resource.getName()) != null)) { + continue; + } + if (resource.isFree()) { + // we can remove this resource. Is newer used currently + LOGGER.config("lockable-resources-plugin: remove node resource '" + resource.getName() + "'."); + resourceIterator.remove(); + } else { + LOGGER.warning("lockable-resources-plugin: can not remove node-resource '" + + resource.getName() + + "'. The resource is currently used (not free)."); + } + } + } + + // --------------------------------------------------------------------------- + private static void mirrorNode(Node node) { + if (node == null) { + return; + } + + LockableResource nodeResource = lrm.fromName(node.getNodeName()); + boolean exist = nodeResource != null; + if (!exist) { + nodeResource = new LockableResource(node.getNodeName()); + LOGGER.config("lockable-resources-plugin: Node-resource '" + nodeResource.getName() + "' will be added."); + } else { + LOGGER.fine("lockable-resources-plugin: Node-resource '" + nodeResource.getName() + "' will be updated."); + } + nodeResource.setLabelsFromString( + node.getAssignedLabels().stream().map(Object::toString).collect(Collectors.joining(" "))); + nodeResource.setNodeResource(true); + nodeResource.setEphemeral(false); + nodeResource.setDescription(node.getNodeDescription()); + + if (!exist) { + lrm.addResource(nodeResource); + } + } +} diff --git a/src/main/java/org/jenkins/plugins/lockableresources/queue/LockRunListener.java b/src/main/java/org/jenkins/plugins/lockableresources/queue/LockRunListener.java index 88f1fc075..ed982bdf3 100644 --- a/src/main/java/org/jenkins/plugins/lockableresources/queue/LockRunListener.java +++ b/src/main/java/org/jenkins/plugins/lockableresources/queue/LockRunListener.java @@ -1,120 +1,126 @@ -/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * - * Copyright (c) 2013, 6WIND S.A. All rights reserved. * - * * - * This file is part of the Jenkins Lockable Resources Plugin and is * - * published under the MIT license. * - * * - * See the "LICENSE.txt" file for more information. * - * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ +/* + * The MIT License + * + * See the "LICENSE.txt" file for full copyright and license information. + */ package org.jenkins.plugins.lockableresources.queue; +import edu.umd.cs.findbugs.annotations.NonNull; +import hudson.EnvVars; import hudson.Extension; -import hudson.matrix.MatrixBuild; import hudson.model.AbstractBuild; import hudson.model.Job; import hudson.model.Run; +import hudson.model.StringParameterValue; import hudson.model.TaskListener; import hudson.model.listeners.RunListener; -import hudson.model.StringParameterValue; - +import java.io.IOException; import java.util.ArrayList; -import java.util.HashSet; import java.util.List; -import java.util.Set; import java.util.logging.Logger; - -import org.jenkins.plugins.lockableresources.LockableResourcesManager; +import java.util.stream.Collectors; import org.jenkins.plugins.lockableresources.LockableResource; -import org.jenkins.plugins.lockableresources.actions.LockedResourcesBuildAction; +import org.jenkins.plugins.lockableresources.LockableResourceProperty; +import org.jenkins.plugins.lockableresources.LockableResourcesManager; import org.jenkins.plugins.lockableresources.actions.ResourceVariableNameAction; @Extension public class LockRunListener extends RunListener> { - static final String LOG_PREFIX = "[lockable-resources]"; - static final Logger LOGGER = Logger.getLogger(LockRunListener.class - .getName()); - - @Override - public void onStarted(Run build, TaskListener listener) { - // Skip locking for multiple configuration projects, - // only the child jobs will actually lock resources. - if (build instanceof MatrixBuild) - return; - - if (build instanceof AbstractBuild) { - Job proj = Utils.getProject(build); - Set required = new HashSet(); - if (proj != null) { - LockableResourcesStruct resources = Utils.requiredResources(proj); - - if (resources != null) { - if (resources.requiredNumber != null || !resources.label.isEmpty() || resources.getResourceMatchScript() != null) { - required.addAll(LockableResourcesManager.get(). - getResourcesFromProject(proj.getFullName())); - } else { - required.addAll(resources.required); - } - - if (LockableResourcesManager.get().lock(required, build, null)) { - build.addAction(LockedResourcesBuildAction - .fromResources(required)); - listener.getLogger().printf("%s acquired lock on %s%n", - LOG_PREFIX, required); - LOGGER.fine(build.getFullDisplayName() - + " acquired lock on " + required); - if (resources.requiredVar != null) { - build.addAction(new ResourceVariableNameAction(new StringParameterValue( - resources.requiredVar, - required.toString().replaceAll("[\\]\\[]", "")))); - } - } else { - listener.getLogger().printf("%s failed to lock %s%n", - LOG_PREFIX, required); - LOGGER.fine(build.getFullDisplayName() + " failed to lock " - + required); - } - } - } - } - - return; - } - - @Override - public void onCompleted(Run build, TaskListener listener) { - // Skip unlocking for multiple configuration projects, - // only the child jobs will actually unlock resources. - if (build instanceof MatrixBuild) - return; - - // obviously project name cannot be obtained here - List required = LockableResourcesManager.get() - .getResourcesFromBuild(build); - if (required.size() > 0) { - LockableResourcesManager.get().unlock(required, build); - listener.getLogger().printf("%s released lock on %s%n", - LOG_PREFIX, required); - LOGGER.fine(build.getFullDisplayName() + " released lock on " - + required); - } - - } - - @Override - public void onDeleted(Run build) { - // Skip unlocking for multiple configuration projects, - // only the child jobs will actually unlock resources. - if (build instanceof MatrixBuild) - return; - - List required = LockableResourcesManager.get() - .getResourcesFromBuild(build); - if (required.size() > 0) { - LockableResourcesManager.get().unlock(required, build); - LOGGER.fine(build.getFullDisplayName() + " released lock on " - + required); - } - } - + static final String LOG_PREFIX = "[lockable-resources]"; + static final Logger LOGGER = Logger.getLogger(LockRunListener.class.getName()); + + @Override + public void onStarted(Run build, TaskListener listener) { + // Skip locking for multiple configuration projects, + // only the child jobs will actually lock resources. + if (build.getClass().getName().equals("hudson.matrix.MatrixBuild")) { + return; + } + + if (build instanceof AbstractBuild) { + AbstractBuild abstractBuild = (AbstractBuild) build; + LockableResourcesManager lrm = LockableResourcesManager.get(); + synchronized (LockableResourcesManager.syncResources) { + Job proj = Utils.getProject(build); + List required = new ArrayList<>(); + + // Resolve build parameters so that ${PARAM} references in + // resource names, labels, and numbers are expanded. + EnvVars buildEnv; + try { + buildEnv = abstractBuild.getEnvironment(listener); + } catch (IOException | InterruptedException e) { + buildEnv = new EnvVars(); + } + + LockableResourcesStruct resources = Utils.requiredResources(proj, buildEnv); + + if (resources != null) { + if (resources.requiredNumber != null + || !resources.label.isEmpty() + || resources.getResourceMatchScriptText() != null) { + required.addAll(lrm.getResourcesFromProject(proj.getFullName())); + } else { + required.addAll(resources.required); + } + + if (lrm.lock(required, build)) { + // build.addAction(LockedResourcesBuildAction.fromResources(required)); + listener.getLogger().printf("%s acquired lock on %s%n", LOG_PREFIX, required); + LOGGER.info(build.getFullDisplayName() + " acquired lock on " + required); + if (resources.requiredVar != null) { + List envsToSet = new ArrayList<>(); + + // add the comma separated list of names acquired + envsToSet.add(new StringParameterValue( + resources.requiredVar, + required.stream() + .map(LockableResource::getName) + .collect(Collectors.joining(",")))); + + // also add a numbered variable for each acquired lock along with properties of the lock + int index = 0; + for (LockableResource lr : required) { + String lockEnvName = resources.requiredVar + index; + envsToSet.add(new StringParameterValue(lockEnvName, lr.getName())); + for (LockableResourceProperty lockProperty : lr.getProperties()) { + String propEnvName = lockEnvName + "_" + lockProperty.getName(); + envsToSet.add(new StringParameterValue(propEnvName, lockProperty.getValue())); + } + ++index; + } + + build.addAction(new ResourceVariableNameAction(envsToSet)); + } + } else { + listener.getLogger().printf("%s failed to lock %s%n", LOG_PREFIX, required); + LOGGER.warning(build.getFullDisplayName() + " failed to lock " + required); + } + } + } + } + } + + @Override + public void onCompleted(Run build, @NonNull TaskListener listener) { + // Skip unlocking for multiple configuration projects, + // only the child jobs will actually unlock resources. + if (build.getClass().getName().equals("hudson.matrix.MatrixBuild")) { + return; + } + LOGGER.info(build.getFullDisplayName()); + LockableResourcesManager.get().unlockBuild(build); + } + + @Override + public void onDeleted(Run build) { + // Skip unlocking for multiple configuration projects, + // only the child jobs will actually unlock resources. + if (build.getClass().getName().equals("hudson.matrix.MatrixBuild")) { + return; + } + LOGGER.info(build.getFullDisplayName()); + LockableResourcesManager.get().unlockBuild(build); + } } diff --git a/src/main/java/org/jenkins/plugins/lockableresources/queue/LockWaitTimeoutException.java b/src/main/java/org/jenkins/plugins/lockableresources/queue/LockWaitTimeoutException.java new file mode 100644 index 000000000..7c9ea1e61 --- /dev/null +++ b/src/main/java/org/jenkins/plugins/lockableresources/queue/LockWaitTimeoutException.java @@ -0,0 +1,18 @@ +/* + * The MIT License + * + * See the "LICENSE.txt" file for full copyright and license information. + */ +package org.jenkins.plugins.lockableresources.queue; + +/** + * Exception thrown when a lock step times out waiting for resource allocation. + */ +public class LockWaitTimeoutException extends Exception { + + private static final long serialVersionUID = 1L; + + public LockWaitTimeoutException(String message) { + super(message); + } +} diff --git a/src/main/java/org/jenkins/plugins/lockableresources/queue/LockWaitTimeoutPeriodicWork.java b/src/main/java/org/jenkins/plugins/lockableresources/queue/LockWaitTimeoutPeriodicWork.java new file mode 100644 index 000000000..a59e4660f --- /dev/null +++ b/src/main/java/org/jenkins/plugins/lockableresources/queue/LockWaitTimeoutPeriodicWork.java @@ -0,0 +1,41 @@ +/* + * The MIT License + * + * See the "LICENSE.txt" file for full copyright and license information. + */ +package org.jenkins.plugins.lockableresources.queue; + +import hudson.Extension; +import hudson.model.PeriodicWork; +import java.util.logging.Level; +import java.util.logging.Logger; +import org.jenkins.plugins.lockableresources.LockableResourcesManager; + +/** + * Periodic work that checks for timed-out lock queue entries. + * + *

This runs every 15 seconds to check if any queued pipeline contexts have exceeded + * their {@code timeoutForAllocateResource}. Without this, timed-out entries would only + * be detected when resources are freed (which might never happen if all resources are + * permanently busy). + */ +@Extension +public class LockWaitTimeoutPeriodicWork extends PeriodicWork { + + private static final Logger LOGGER = Logger.getLogger(LockWaitTimeoutPeriodicWork.class.getName()); + + @Override + public long getRecurrencePeriod() { + return 15_000L; // 15 seconds + } + + @Override + protected void doRun() { + LockableResourcesManager lrm = LockableResourcesManager.get(); + if (lrm.getCurrentQueuedContext().isEmpty()) { + return; + } + LOGGER.log(Level.FINEST, "Checking for timed-out lock queue entries"); + lrm.checkTimeouts(); + } +} diff --git a/src/main/java/org/jenkins/plugins/lockableresources/queue/LockableResourcesCandidatesStruct.java b/src/main/java/org/jenkins/plugins/lockableresources/queue/LockableResourcesCandidatesStruct.java deleted file mode 100644 index b59e070c1..000000000 --- a/src/main/java/org/jenkins/plugins/lockableresources/queue/LockableResourcesCandidatesStruct.java +++ /dev/null @@ -1,25 +0,0 @@ -package org.jenkins.plugins.lockableresources.queue; - -import java.util.List; - -import org.jenkins.plugins.lockableresources.LockableResource; - -public class LockableResourcesCandidatesStruct { - - public List candidates; - public int requiredAmount; - public List selected; - - public LockableResourcesCandidatesStruct(List candidates, int requiredAmount) { - this.candidates = candidates; - this.requiredAmount = requiredAmount; - } - - @Override - public String toString() - { - return "LockableResourcesCandidatesStruct [candidates=" + candidates + ", requiredAmount=" + requiredAmount - + ", selected=" + selected + "]"; - } - -} diff --git a/src/main/java/org/jenkins/plugins/lockableresources/queue/LockableResourcesQueueTaskDispatcher.java b/src/main/java/org/jenkins/plugins/lockableresources/queue/LockableResourcesQueueTaskDispatcher.java index 8fb414dee..66f3de1d8 100644 --- a/src/main/java/org/jenkins/plugins/lockableresources/queue/LockableResourcesQueueTaskDispatcher.java +++ b/src/main/java/org/jenkins/plugins/lockableresources/queue/LockableResourcesQueueTaskDispatcher.java @@ -1,192 +1,279 @@ -/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * - * Copyright (c) 2013, 6WIND S.A. All rights reserved. * - * * - * This file is part of the Jenkins Lockable Resources Plugin and is * - * published under the MIT license. * - * * - * See the "LICENSE.txt" file for more information. * - * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ +/* + * The MIT License + * + * See the "LICENSE.txt" file for full copyright and license information. + */ package org.jenkins.plugins.lockableresources.queue; -import com.google.common.cache.Cache; -import com.google.common.cache.CacheBuilder; +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; import edu.umd.cs.findbugs.annotations.NonNull; +import hudson.EnvVars; import hudson.Extension; -import hudson.matrix.MatrixConfiguration; -import hudson.matrix.MatrixProject; -import hudson.model.AbstractProject; +import hudson.ExtensionList; import hudson.model.Job; +import hudson.model.ParameterValue; +import hudson.model.ParametersAction; import hudson.model.Queue; -import hudson.model.queue.QueueTaskDispatcher; import hudson.model.queue.CauseOfBlockage; -import hudson.model.ParametersAction; -import hudson.model.ParameterValue; - +import hudson.model.queue.QueueTaskDispatcher; import java.util.Date; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; import java.util.logging.Level; import java.util.logging.Logger; - -import org.apache.commons.lang.time.DateUtils; import org.jenkins.plugins.lockableresources.LockableResource; import org.jenkins.plugins.lockableresources.LockableResourcesManager; +import org.jenkins.plugins.lockableresources.RequiredResourcesProperty; import org.kohsuke.accmod.Restricted; import org.kohsuke.accmod.restrictions.NoExternalUse; @Extension public class LockableResourcesQueueTaskDispatcher extends QueueTaskDispatcher { - private transient Cache lastLogged = CacheBuilder.newBuilder().expireAfterWrite(30, TimeUnit.MINUTES).build(); - - static final Logger LOGGER = Logger - .getLogger(LockableResourcesQueueTaskDispatcher.class.getName()); - - @Override - public CauseOfBlockage canRun(Queue.Item item) { - // Skip locking for multiple configuration projects, - // only the child jobs will actually lock resources. - if (item.task instanceof MatrixProject) - return null; - - Job project = Utils.getProject(item); - if (project == null) - return null; - - LockableResourcesStruct resources = Utils.requiredResources(project); - if (resources == null || - (resources.required.isEmpty() && resources.label.isEmpty() && resources.getResourceMatchScript() == null)) { - return null; - } - - int resourceNumber; - try { - resourceNumber = Integer.parseInt(resources.requiredNumber); - } catch (NumberFormatException e) { - resourceNumber = 0; - } - - LOGGER.finest(project.getName() + - " trying to get resources with these details: " + resources); - - if (resourceNumber > 0 || !resources.label.isEmpty() || resources.getResourceMatchScript() != null) { - Map params = new HashMap(); - - // Inject Build Parameters, if possible and applicable to the "item" type - try { - List itemparams = item.getActions(ParametersAction.class); - if (itemparams != null) { - for ( ParametersAction actparam : itemparams) { - if (actparam == null) continue; - for ( ParameterValue p : actparam.getParameters() ) { - if (p == null) continue; - params.put(p.getName(), p.getValue()); - } - } - } - } catch(Exception ex) { - // Report the error and go on with the build - - // perhaps this item is not a build with args, etc. - // Note this is likely to fail a bit later in such case. - if (LOGGER.isLoggable(Level.WARNING)) { - if (lastLogged.getIfPresent(item.getId()) == null) { - lastLogged.put(item.getId(), new Date()); - String itemName = project.getFullName() + " (id=" + item.getId() + ")"; - LOGGER.log(Level.WARNING, "Failed to get build params from item " + itemName, ex); - } - } - } - - if (item.task instanceof MatrixConfiguration) { - MatrixConfiguration matrix = (MatrixConfiguration) item.task; - params.putAll(matrix.getCombination()); - } - - final List selected ; - try { - selected = LockableResourcesManager.get().tryQueue( - resources, - item.getId(), - project.getFullName(), - resourceNumber, - params, - LOGGER); - } catch(ExecutionException ex) { - Throwable toReport = ex.getCause(); - if (toReport == null) { // We care about the cause only - toReport = ex; - } - if (LOGGER.isLoggable(Level.WARNING)) { - if (lastLogged.getIfPresent(item.getId()) == null) { - lastLogged.put(item.getId(), new Date()); - - String itemName = project.getFullName() + " (id=" + item.getId() + ")"; - LOGGER.log(Level.WARNING, "Failed to queue item " + itemName, toReport.getMessage()); - } - } - - return new BecauseResourcesQueueFailed(resources, toReport); - } - - if (selected != null) { - LOGGER.finest(project.getName() + " reserved resources " + selected); - return null; - } else { - LOGGER.finest(project.getName() + " waiting for resources"); - return new BecauseResourcesLocked(resources); - } - - } else { - if (LockableResourcesManager.get().queue(resources.required, item.getId(), project.getFullDisplayName())) { - LOGGER.finest(project.getName() + " reserved resources " + resources.required); - return null; - } else { - LOGGER.finest(project.getName() + " waiting for resources " - + resources.required); - return new BecauseResourcesLocked(resources); - } - } - } - - public static class BecauseResourcesLocked extends CauseOfBlockage { - - private final LockableResourcesStruct rscStruct; - - public BecauseResourcesLocked(LockableResourcesStruct r) { - this.rscStruct = r; - } - - @Override - public String getShortDescription() { - if (this.rscStruct.label.isEmpty()) - return "Waiting for resources " + rscStruct.required.toString(); - else - return "Waiting for resources with label " + rscStruct.label; - } - } - - // Only for UI - @Restricted(NoExternalUse.class) - public static class BecauseResourcesQueueFailed extends CauseOfBlockage { - - @NonNull - private final LockableResourcesStruct resources; - @NonNull - private final Throwable cause; - - public BecauseResourcesQueueFailed(@NonNull LockableResourcesStruct resources, @NonNull Throwable cause) { - this.cause = cause; - this.resources = resources; - } - - @Override - public String getShortDescription() { - //TODO: Just a copy-paste from BecauseResourcesLocked, seems strange - String resourceInfo = (resources.label.isEmpty()) ? resources.required.toString() : "with label " + resources.label; - return "Execution failed while acquiring the resource " + resourceInfo + ". " + cause.getMessage(); - } - } + private transient Cache lastLogged = + Caffeine.newBuilder().expireAfterWrite(30, TimeUnit.MINUTES).build(); + + /** Tracks the deadline (epoch millis) for each queue item waiting for resources. */ + private final transient ConcurrentHashMap deadlines = new ConcurrentHashMap<>(); + + static final Logger LOGGER = Logger.getLogger(LockableResourcesQueueTaskDispatcher.class.getName()); + + @Override + public CauseOfBlockage canRun(Queue.Item item) { + // Skip locking for multiple configuration projects, + // only the child jobs will actually lock resources. + if (item.task.getClass().getName().equals("hudson.matrix.MatrixProject")) { + return null; + } + + Job project = Utils.getProject(item); + if (project == null) return null; + + // Extract build parameters so that ${PARAM} references in resource + // names, labels, and numbers are expanded before scheduling. + EnvVars paramEnv = Utils.getParametersAsEnvVars(item); + LockableResourcesStruct resources = Utils.requiredResources(project, paramEnv); + if (resources == null + || (resources.required.isEmpty() + && resources.label.isEmpty() + && resources.getResourceMatchScriptText() == null)) { + return null; + } + + int resourceNumber; + try { + resourceNumber = Integer.parseInt(resources.requiredNumber); + } catch (NumberFormatException e) { + resourceNumber = 0; + } + + LOGGER.finest(project.getName() + " trying to get resources with these details: " + resources); + + if (resourceNumber > 0 || !resources.label.isEmpty() || resources.getResourceMatchScriptText() != null) { + Map params = new HashMap<>(); + + // Inject Build Parameters, if possible and applicable to the "item" type + try { + List itemparams = item.getActions(ParametersAction.class); + for (ParametersAction actparam : itemparams) { + if (actparam == null) continue; + for (ParameterValue p : actparam.getParameters()) { + if (p == null) continue; + params.put(p.getName(), p.getValue()); + } + } + } catch (Exception ex) { + // Report the error and go on with the build - + // perhaps this item is not a build with args, etc. + // Note this is likely to fail a bit later in such case. + if (LOGGER.isLoggable(Level.WARNING)) { + if (lastLogged.getIfPresent(item.getId()) == null) { + lastLogged.put(item.getId(), new Date()); + String itemName = project.getFullName() + " (id=" + item.getId() + ")"; + LOGGER.log(Level.WARNING, "Failed to get build params from item " + itemName, ex); + } + } + } + + for (var ma : ExtensionList.lookup(Utils.MatrixAssist.class)) { + params.putAll(ma.getCombination(project)); + } + + final List selected; + try { + selected = LockableResourcesManager.get() + .tryQueue(resources, item.getId(), project.getFullName(), resourceNumber, params, LOGGER); + } catch (ExecutionException ex) { + Throwable toReport = ex.getCause(); + if (toReport == null) { // We care about the cause only + toReport = ex; + } + if (LOGGER.isLoggable(Level.WARNING)) { + if (lastLogged.getIfPresent(item.getId()) == null) { + lastLogged.put(item.getId(), new Date()); + + String itemName = project.getFullName() + " (id=" + item.getId() + ")"; + LOGGER.log(Level.WARNING, "Failed to queue item " + itemName, toReport.getMessage()); + } + } + + return new BecauseResourcesQueueFailed(resources, toReport); + } + + if (selected != null) { + LOGGER.finest(project.getName() + " reserved resources " + selected); + deadlines.remove(item.getId()); + return null; + } else { + LOGGER.finest(project.getName() + " waiting for resources"); + CauseOfBlockage timeout = checkFreestyleTimeout(item, project); + if (timeout != null) return timeout; + return new BecauseResourcesLocked(resources); + } + + } else { + if (LockableResourcesManager.get().queue(resources.required, item.getId(), project.getFullDisplayName())) { + LOGGER.finest(project.getName() + " reserved resources " + resources.required); + deadlines.remove(item.getId()); + return null; + } else { + LOGGER.finest(project.getName() + " waiting for resources " + resources.required); + CauseOfBlockage timeout = checkFreestyleTimeout(item, project); + if (timeout != null) return timeout; + return new BecauseResourcesLocked(resources); + } + } + } + + /** + * Checks whether a freestyle queue item has exceeded the configured lock timeout. + * If timed out, the item is cancelled from the Jenkins queue. + * + * @return a {@link BecauseResourcesTimeout} if timed out, {@code null} otherwise + */ + private CauseOfBlockage checkFreestyleTimeout(Queue.Item item, Job project) { + RequiredResourcesProperty prop = project.getProperty(RequiredResourcesProperty.class); + if (prop == null || prop.getLockTimeout() <= 0) { + return null; + } + + long now = System.currentTimeMillis(); + long deadline = deadlines.computeIfAbsent(item.getId(), k -> { + long timeoutMillis; + try { + timeoutMillis = TimeUnit.valueOf(prop.getLockTimeoutUnit()).toMillis(prop.getLockTimeout()); + } catch (IllegalArgumentException e) { + timeoutMillis = TimeUnit.MINUTES.toMillis(prop.getLockTimeout()); + } + return now + timeoutMillis; + }); + + if (now >= deadline) { + LOGGER.log(Level.INFO, "{0} timed out waiting for lockable resources (timeout: {1} {2})", new Object[] { + project.getFullName(), + prop.getLockTimeout(), + prop.getLockTimeoutUnit().toLowerCase(java.util.Locale.ENGLISH) + }); + deadlines.remove(item.getId()); + // Cancel the queue item + jenkins.model.Jenkins.get().getQueue().cancel(item); + return new BecauseResourcesTimeout(project.getFullName(), prop.getLockTimeout(), prop.getLockTimeoutUnit()); + } + return null; + } + + public static class BecauseResourcesLocked extends CauseOfBlockage { + + private final LockableResourcesStruct rscStruct; + + public BecauseResourcesLocked(LockableResourcesStruct r) { + this.rscStruct = r; + } + + @Override + public String getShortDescription() { + if (this.rscStruct.label.isEmpty()) { + if (!this.rscStruct.required.isEmpty()) { + return "Waiting for resource instances " + rscStruct.required; + } else { + final String systemGroovyScript = this.rscStruct.getResourceMatchScriptText(); + if (systemGroovyScript != null) { + // Empty or not... just keep the logic in sync + // with tryQueue() in LockableResourcesManager + if (systemGroovyScript.isEmpty()) { + return "Waiting for resources identified by custom script (which is empty)"; + } else { + return "Waiting for resources identified by custom script"; + } + } + // TODO: Developers should extend here if LockableResourcesStruct is extended + LOGGER.log(Level.WARNING, "Failed to classify reason of waiting for resource: " + this.rscStruct); + return "Waiting for lockable resources"; + } + } else { + return "Waiting for resources with label " + rscStruct.label; + } + } + } + + // Only for UI + @Restricted(NoExternalUse.class) + public static class BecauseResourcesQueueFailed extends CauseOfBlockage { + + @NonNull + private final LockableResourcesStruct resources; + + @NonNull + private final Throwable cause; + + public BecauseResourcesQueueFailed(@NonNull LockableResourcesStruct resources, @NonNull Throwable cause) { + this.cause = cause; + this.resources = resources; + } + + @Override + public String getShortDescription() { + // TODO: Just a copy-paste from BecauseResourcesLocked, seems strange + String resourceInfo = + resources.label.isEmpty() ? resources.required.toString() : "with label " + resources.label; + return "Execution failed while acquiring the resource " + resourceInfo + ". " + cause.getMessage(); + } + } + + // Only for UI + @Restricted(NoExternalUse.class) + public static class BecauseResourcesTimeout extends CauseOfBlockage { + + private final String projectName; + private final long timeout; + private final String timeoutUnit; + + public BecauseResourcesTimeout(String projectName, long timeout, String timeoutUnit) { + this.projectName = projectName; + this.timeout = timeout; + this.timeoutUnit = timeoutUnit; + } + + @Override + public String getShortDescription() { + return projectName + " cancelled: timed out after " + timeout + " " + + timeoutUnit.toLowerCase(java.util.Locale.ENGLISH) + + " waiting for lockable resources"; + } + + /** + * Signals that this blockage is fatal and the queue item should be removed. + * Since Jenkins 2.532 (core PR #11173), the queue checks this flag and + * cancels the item itself instead of re-adding it as a {@code BlockedItem}. + */ + @Override + public boolean isFatal() { + return true; + } + } } diff --git a/src/main/java/org/jenkins/plugins/lockableresources/queue/LockableResourcesStruct.java b/src/main/java/org/jenkins/plugins/lockableresources/queue/LockableResourcesStruct.java index 27b991c1b..61dbd8354 100644 --- a/src/main/java/org/jenkins/plugins/lockableresources/queue/LockableResourcesStruct.java +++ b/src/main/java/org/jenkins/plugins/lockableresources/queue/LockableResourcesStruct.java @@ -1,128 +1,169 @@ -/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * - * Copyright (c) 2013, Aki Asikainen. All rights reserved. * - * * - * This file is part of the Jenkins Lockable Resources Plugin and is * - * published under the MIT license. * - * * - * See the "LICENSE.txt" file for more information. * - * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ +/* + * The MIT License + * + * See the "LICENSE.txt" file for full copyright and license information. + */ package org.jenkins.plugins.lockableresources.queue; +import edu.umd.cs.findbugs.annotations.CheckForNull; +import edu.umd.cs.findbugs.annotations.Nullable; +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import hudson.EnvVars; -import java.io.IOException; -import java.io.ObjectOutputStream; - +import hudson.model.Descriptor; import java.io.Serializable; import java.util.ArrayList; +import java.util.Date; import java.util.List; -import javax.annotation.CheckForNull; - import org.jenkins.plugins.lockableresources.LockableResource; import org.jenkins.plugins.lockableresources.LockableResourcesManager; import org.jenkins.plugins.lockableresources.RequiredResourcesProperty; import org.jenkins.plugins.lockableresources.util.SerializableSecureGroovyScript; import org.jenkinsci.plugins.scriptsecurity.sandbox.groovy.SecureGroovyScript; - -import edu.umd.cs.findbugs.annotations.Nullable; +import org.kohsuke.accmod.Restricted; +import org.kohsuke.accmod.restrictions.NoExternalUse; public class LockableResourcesStruct implements Serializable { - public List required; - public String label; - public String requiredVar; - public String requiredNumber; - - @CheckForNull - private final SerializableSecureGroovyScript serializableResourceMatchScript; - - @CheckForNull - private transient SecureGroovyScript resourceMatchScript; - - public LockableResourcesStruct(RequiredResourcesProperty property, - EnvVars env) { - required = new ArrayList(); - for (String name : property.getResources()) { - LockableResource r = LockableResourcesManager.get().fromName( - env.expand(name)); - if (r != null) { - this.required.add(r); - } - } - - label = env.expand(property.getLabelName()); - if (label == null) - label = ""; - - resourceMatchScript = property.getResourceMatchScript(); - serializableResourceMatchScript = new SerializableSecureGroovyScript(resourceMatchScript); - - requiredVar = property.getResourceNamesVar(); - - requiredNumber = property.getResourceNumber(); - if (requiredNumber != null && requiredNumber.equals("0")) - requiredNumber = null; - } - - /** - * Light-weight constructor for declaring a resource only. - * @param resources Resources to be required - */ - public LockableResourcesStruct(@Nullable List resources) { - this(resources, null, 0); - } - - public LockableResourcesStruct(@Nullable List resources, @Nullable String label, int quantity, String variable) { - this(resources, label, quantity); - requiredVar = variable; - } - - public LockableResourcesStruct(@Nullable List resources, @Nullable String label, int quantity) { - required = new ArrayList(); - if (resources != null) { - for (String resource : resources) { - LockableResource r = LockableResourcesManager.get().fromName(resource); - if (r != null) { - this.required.add(r); - } - } - } - - this.label = label; - if (this.label == null) { - this.label = ""; - } - - this.requiredNumber = null; - if (quantity > 0) { - this.requiredNumber = String.valueOf(quantity); - } - - // We do not support - this.serializableResourceMatchScript = null; - this.resourceMatchScript = null; - } - - /** - * Gets a system Groovy script to be executed in order to determine if the {@link LockableResource} matches the condition. - * @return System Groovy Script if defined - * @since TODO - * @see LockableResource#scriptMatches(org.jenkinsci.plugins.scriptsecurity.sandbox.groovy.SecureGroovyScript, java.util.Map) - */ - @CheckForNull - public SecureGroovyScript getResourceMatchScript() { - if (resourceMatchScript == null && serializableResourceMatchScript != null) { - resourceMatchScript = serializableResourceMatchScript.rehydrate(); - } - return resourceMatchScript; - } - - public String toString() { - return "Required resources: " + this.required + - ", Required label: " + this.label + - ", Required label script: " + (this.resourceMatchScript != null ? this.resourceMatchScript.getScript() : "") + - ", Variable name: " + this.requiredVar + - ", Number of resources: " + this.requiredNumber; - } - - private static final long serialVersionUID = 1L; + // Note to developers: if the set of selection criteria variables evolves, + // do not forget to update LockableResourcesQueueTaskDispatcher.java with + // class BecauseResourcesLocked method getShortDescription() for user info. + public List required; + public String label; + public String requiredVar; + public String requiredNumber; + public long queuedAt = 0; + + @CheckForNull + private final SerializableSecureGroovyScript serializableResourceMatchScript; + + @SuppressFBWarnings("SE_TRANSIENT_FIELD_NOT_RESTORED") + @CheckForNull + private transient SecureGroovyScript resourceMatchScript; + + private static final long serialVersionUID = 1L; + + public LockableResourcesStruct(RequiredResourcesProperty property, EnvVars env) { + queuedAt = new Date().getTime(); + required = new ArrayList<>(); + + List names = new ArrayList<>(); + for (String name : property.getResources()) { + String resourceName = env.expand(name); + if (resourceName == null) { + continue; + } + names.add(resourceName); + } + + LockableResourcesManager lrm = LockableResourcesManager.get(); + this.required = lrm.fromNames(names, /*create un-existent resources */ true); + + label = env.expand(property.getLabelName()); + if (label == null) label = ""; + + resourceMatchScript = property.getResourceMatchScript(); + serializableResourceMatchScript = new SerializableSecureGroovyScript(resourceMatchScript); + + requiredVar = property.getResourceNamesVar(); + + requiredNumber = env.expand(property.getResourceNumber()); + if (requiredNumber != null && requiredNumber.equals("0")) requiredNumber = null; + } + + /** + * Light-weight constructor for declaring a resource only. + * + * @param resources Resources to be required + */ + public LockableResourcesStruct(@Nullable List resources) { + this(resources, null, 0); + } + + public LockableResourcesStruct( + @Nullable List resources, @Nullable String label, int quantity, String variable) { + this(resources, label, quantity); + requiredVar = variable; + } + + public LockableResourcesStruct(@Nullable List resources, @Nullable String label, int quantity) { + queuedAt = new Date().getTime(); + required = new ArrayList<>(); + if (resources != null) { + /// FIXME do we shall check here if resources.size() >= quantity + for (String resource : resources) { + LockableResource r = LockableResourcesManager.get().fromName(resource); + if (r != null) { + this.required.add(r); + } + } + } + + this.label = label; + if (this.label == null) { + this.label = ""; + } + + this.requiredNumber = null; + if (quantity > 0) { + this.requiredNumber = String.valueOf(quantity); + } + + // We do not support + this.serializableResourceMatchScript = null; + this.resourceMatchScript = null; + } + + /** + * Gets a system Groovy script to be executed in order to determine if the {@link + * LockableResource} matches the condition. + * + * @return System Groovy Script if defined + * @see + * LockableResource#scriptMatches(org.jenkinsci.plugins.scriptsecurity.sandbox.groovy.SecureGroovyScript, + * java.util.Map) + * @since 2.1 + */ + @CheckForNull + public SecureGroovyScript getResourceMatchScript() throws Descriptor.FormException { + if (resourceMatchScript == null && serializableResourceMatchScript != null) { + // this is probably high defensive code, because + resourceMatchScript = serializableResourceMatchScript.rehydrate(); + } + return resourceMatchScript; + } + + @CheckForNull + public String getResourceMatchScriptText() { + return serializableResourceMatchScript != null ? serializableResourceMatchScript.getScript() : null; + } + + @Override + public String toString() { + String str = ""; + if (this.required != null && !this.required.isEmpty()) { + str += "Required resources: " + this.required; + } + if (this.label != null && !this.label.isEmpty()) { + str += "Required label: " + this.label; + } + if (this.resourceMatchScript != null) { + str += "Required label script: " + this.resourceMatchScript.getScript(); + } + if (this.requiredVar != null) { + str += ", Variable name: " + this.requiredVar; + } + if (this.requiredNumber != null) { + str += ", Number of resources: " + this.requiredNumber; + } + return str; + } + + /** Check if the *resource* is required by this struct / queue */ + @Restricted(NoExternalUse.class) + public boolean isResourceRequired(final LockableResource resource) { + if (resource == null) { + return false; + } + return LockableResourcesManager.getResourcesNames(this.required).contains(resource.getName()); + } } diff --git a/src/main/java/org/jenkins/plugins/lockableresources/queue/QueuedContextStruct.java b/src/main/java/org/jenkins/plugins/lockableresources/queue/QueuedContextStruct.java index 8d573ae28..f6b089095 100644 --- a/src/main/java/org/jenkins/plugins/lockableresources/queue/QueuedContextStruct.java +++ b/src/main/java/org/jenkins/plugins/lockableresources/queue/QueuedContextStruct.java @@ -1,20 +1,24 @@ -/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * - * Copyright (c) 2016, Florian Hug. All rights reserved. * - * * - * This file is part of the Jenkins Lockable Resources Plugin and is * - * published under the MIT license. * - * * - * See the "LICENSE.txt" file for more information. * - * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ +/* + * The MIT License + * + * See the "LICENSE.txt" file for full copyright and license information. + */ package org.jenkins.plugins.lockableresources.queue; +import edu.umd.cs.findbugs.annotations.CheckForNull; +import hudson.model.Run; +import hudson.model.TaskListener; +import java.io.IOException; +import java.io.PrintStream; import java.io.Serializable; import java.util.List; - +import java.util.UUID; +import java.util.concurrent.TimeUnit; +import java.util.logging.Level; +import java.util.logging.Logger; import org.jenkinsci.plugins.workflow.steps.StepContext; -import org.jenkins.plugins.lockableresources.queue.LockableResourcesStruct; - -import edu.umd.cs.findbugs.annotations.Nullable; +import org.kohsuke.accmod.Restricted; +import org.kohsuke.accmod.restrictions.NoExternalUse; /* * This class is used to queue pipeline contexts @@ -23,50 +27,257 @@ */ public class QueuedContextStruct implements Serializable { - /* - * Reference to the pipeline step context. - */ - private StepContext context; - - /* - * Reference to the resources required by the step context. - */ - private List lockableResourcesStruct; - - /* - * Description of the required resources used within logging messages. - */ - private String resourceDescription; - - /* - * Constructor for the QueuedContextStruct class. - */ - public QueuedContextStruct(StepContext context, List lockableResourcesStruct, String resourceDescription) { - this.context = context; - this.lockableResourcesStruct = lockableResourcesStruct; - this.resourceDescription = resourceDescription; - } - - /* - * Gets the pipeline step context. - */ - public StepContext getContext() { - return this.context; - } - - /* - * Gets the required resources. - */ - public List getResources() { - return this.lockableResourcesStruct; - } - - /* - * Gets the resource description for logging messages. - */ - public String getResourceDescription() { - return this.resourceDescription; - } - - private static final long serialVersionUID = 1L; + /* + * Reference to the pipeline step context. + */ + private StepContext context; + + /* + * Reference to the resources required by the step context. + */ + private List lockableResourcesStruct; + + /* + * Description of the required resources used within logging messages. + */ + private String resourceDescription; + + /* + * Name of the variable to save the locks taken. + */ + private String variableName; + + /* + * The reason why the resource is being locked. + */ + private String reason; + + private int priority = 0; + + /* + * Timeout for waiting to acquire the resource, in the specified timeoutUnit. + * 0 means no timeout (wait indefinitely). + */ + private long timeoutForAllocateResource = 0; + + /* + * Time unit for the timeout. Defaults to MINUTES. + */ + private String timeoutUnit = "MINUTES"; + + /* + * Pre-computed absolute deadline (epoch millis) when this entry times out. + * 0 means no timeout. Calculated once at construction time to avoid + * repeated TimeUnit.valueOf() + toMillis() on every queue check. + */ + private long timeoutDeadlineMillis = 0; + + // cached candidates + public transient List candidates = null; + + private static final Logger LOGGER = Logger.getLogger(QueuedContextStruct.class.getName()); + + private String id = null; + + /* + * Constructor for the QueuedContextStruct class. + */ + @Restricted(NoExternalUse.class) + public QueuedContextStruct( + StepContext context, + List lockableResourcesStruct, + String resourceDescription, + String variableName, + int priority) { + this(context, lockableResourcesStruct, resourceDescription, variableName, priority, null, 0, "MINUTES"); + } + + /* + * Constructor for the QueuedContextStruct class with reason and timeout. + */ + @Restricted(NoExternalUse.class) + public QueuedContextStruct( + StepContext context, + List lockableResourcesStruct, + String resourceDescription, + String variableName, + int priority, + String reason, + long timeoutForAllocateResource, + String timeoutUnit) { + this.context = context; + this.lockableResourcesStruct = lockableResourcesStruct; + this.resourceDescription = resourceDescription; + this.variableName = variableName; + this.priority = priority; + this.reason = reason; + this.timeoutForAllocateResource = timeoutForAllocateResource; + this.timeoutUnit = timeoutUnit != null ? timeoutUnit : "MINUTES"; + this.id = UUID.randomUUID().toString(); + + // Pre-compute deadline once to avoid repeated calculation on every queue check + if (timeoutForAllocateResource > 0) { + try { + TimeUnit unit = TimeUnit.valueOf(this.timeoutUnit); + this.timeoutDeadlineMillis = System.currentTimeMillis() + unit.toMillis(timeoutForAllocateResource); + } catch (IllegalArgumentException e) { + LOGGER.warning("Invalid timeoutUnit '" + this.timeoutUnit + "', timeout disabled"); + this.timeoutDeadlineMillis = 0; + } + } + } + + @Restricted(NoExternalUse.class) + public int compare(QueuedContextStruct other) { + if (this.priority > other.getPriority()) return -1; + else if (this.priority == other.getPriority()) return 0; + else return 1; + } + + @Restricted(NoExternalUse.class) + public int getPriority() { + return this.priority; + } + + @Restricted(NoExternalUse.class) + public String getId() { + if (this.id == null) { + this.id = UUID.randomUUID().toString(); + } + return this.id; + } + + /* + * Gets the pipeline step context. + */ + @Restricted(NoExternalUse.class) + public StepContext getContext() { + return this.context; + } + + /** Return build, where is the resource used. */ + @CheckForNull + @Restricted(NoExternalUse.class) + public Run getBuild() { + try { + if (this.getContext() == null) { + return null; + } + return this.getContext().get(Run.class); + } catch (Exception e) { + // for some reason there is no Run object for this context + LOGGER.log( + Level.WARNING, + "Cannot get the build object from the context to proceed with lock. The build probably does not exists (deleted?)", + e); + return null; + } + } + + @Restricted(NoExternalUse.class) + public boolean isValid() { + Run run = this.getBuild(); + if (run == null || run.isBuilding() == false) { + // skip this one, for some reason there is no Run object for this context + LOGGER.warning("The queue " + this + " will be removed, because the build does not exists"); + return false; + } + return true; + } + + @Restricted(NoExternalUse.class) + /* + * Gets the required resources. + */ + public List getResources() { + return this.lockableResourcesStruct; + } + + @Restricted(NoExternalUse.class) + /* + * Gets the resource description for logging messages. + */ + public String getResourceDescription() { + return this.resourceDescription; + } + + @Restricted(NoExternalUse.class) + /* + * Gets the variable name to save the locks taken. + */ + public String getVariableName() { + return this.variableName; + } + + /** + * Checks whether this queued context has exceeded its allocation timeout. + * Uses a pre-computed deadline for performance since this is called on every queue check. + * + * @return true if a timeout was set and has expired, false otherwise + */ + @Restricted(NoExternalUse.class) + public boolean isTimedOut() { + return timeoutDeadlineMillis > 0 && System.currentTimeMillis() > timeoutDeadlineMillis; + } + + /** + * Returns the pre-computed deadline (epoch millis) when this entry times out. + * 0 means no timeout is configured. + */ + @Restricted(NoExternalUse.class) + public long getTimeoutDeadlineMillis() { + return this.timeoutDeadlineMillis; + } + + /** + * Returns the configured timeout for resource allocation. + */ + @Restricted(NoExternalUse.class) + public long getTimeoutForAllocateResource() { + return this.timeoutForAllocateResource; + } + + /** + * Returns the time unit for the allocation timeout. + */ + @Restricted(NoExternalUse.class) + public String getTimeoutUnit() { + return this.timeoutUnit; + } + + @Restricted(NoExternalUse.class) + /* + * Gets the reason for locking. + */ + public String getReason() { + return this.reason; + } + + @Restricted(NoExternalUse.class) + public String toString() { + return "build: " + + this.getBuild() + + " resources: " + + this.getResourceDescription() + + " priority: " + + this.priority + + " id: " + + this.getId(); + } + + @Restricted(NoExternalUse.class) + public PrintStream getLogger() { + PrintStream logger = null; + try { + TaskListener taskListener = this.getContext().get(TaskListener.class); + if (taskListener != null) { + logger = taskListener.getLogger(); + } + } catch (IOException | InterruptedException e) { + LOGGER.log(Level.FINE, "Could not get logger for next context: " + e, e); + } + return logger; + } + + private static final long serialVersionUID = 1L; } diff --git a/src/main/java/org/jenkins/plugins/lockableresources/queue/Utils.java b/src/main/java/org/jenkins/plugins/lockableresources/queue/Utils.java index ccbf9040b..234937686 100644 --- a/src/main/java/org/jenkins/plugins/lockableresources/queue/Utils.java +++ b/src/main/java/org/jenkins/plugins/lockableresources/queue/Utils.java @@ -1,48 +1,138 @@ -/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * - * Copyright (c) 2013, 6WIND S.A. All rights reserved. * - * * - * This file is part of the Jenkins Lockable Resources Plugin and is * - * published under the MIT license. * - * * - * See the "LICENSE.txt" file for more information. * - * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ +/* + * The MIT License + * + * See the "LICENSE.txt" file for full copyright and license information. + */ package org.jenkins.plugins.lockableresources.queue; +import edu.umd.cs.findbugs.annotations.CheckForNull; +import edu.umd.cs.findbugs.annotations.NonNull; import hudson.EnvVars; +import hudson.ExtensionList; import hudson.matrix.MatrixConfiguration; import hudson.model.Job; +import hudson.model.ParameterValue; +import hudson.model.ParametersAction; import hudson.model.Queue; - import hudson.model.Run; +import java.util.List; +import java.util.Map; +import java.util.regex.Pattern; +import org.jenkins.plugins.lockableresources.ExcludeFromJacocoGeneratedReport; import org.jenkins.plugins.lockableresources.RequiredResourcesProperty; +import org.jenkinsci.plugins.variant.OptionalExtension; +import org.kohsuke.accmod.Restricted; +import org.kohsuke.accmod.restrictions.NoExternalUse; + +public final class Utils { + private Utils() {} + + /** Pattern to detect {@code ${...}} variable references in configuration values. */ + private static final Pattern VARIABLE_PATTERN = Pattern.compile("\\$\\{[^}]+}"); + + @CheckForNull + public static Job getProject(@NonNull Queue.Item item) { + if (item.task instanceof Job) return (Job) item.task; + return null; + } + + @NonNull + public static Job getProject(@NonNull Run build) { + return build.getParent(); + } + + /** + * Build the required-resources structure for a project, without additional environment variables. + * + * @see #requiredResources(Job, EnvVars) + */ + @Deprecated + @ExcludeFromJacocoGeneratedReport + @CheckForNull + @Restricted(NoExternalUse.class) + public static LockableResourcesStruct requiredResources(@NonNull Job project) { + return requiredResources(project, null); + } + + /** + * Build the required-resources structure for a project, merging any additional + * environment variables (e.g. build parameters) into the expansion context. + * + * @param project the job whose {@link RequiredResourcesProperty} is read + * @param additionalEnv extra variables to use when expanding {@code ${...}} references; + * may be {@code null} + * @return the struct, or {@code null} if the project has no lockable-resource property + */ + @CheckForNull + @Restricted(NoExternalUse.class) + public static LockableResourcesStruct requiredResources( + @NonNull Job project, @CheckForNull EnvVars additionalEnv) { + EnvVars env = new EnvVars(); + + for (var ma : ExtensionList.lookup(MatrixAssist.class)) { + env.putAll(ma.getCombination(project)); + project = ma.getMainProject(project); + } + + if (additionalEnv != null) { + env.putAll(additionalEnv); + } + + RequiredResourcesProperty property = project.getProperty(RequiredResourcesProperty.class); + if (property != null) return new LockableResourcesStruct(property, env); -public class Utils { + return null; + } - public static Job getProject(Queue.Item item) { - if (item.task instanceof Job) - return (Job) item.task; - return null; - } + /** + * Extract build parameters from a {@link Queue.Item} and return them as {@link EnvVars} + * so that {@code ${PARAM}} references in resource names, labels and numbers are expanded. + */ + @NonNull + @Restricted(NoExternalUse.class) + public static EnvVars getParametersAsEnvVars(@NonNull Queue.Item item) { + EnvVars env = new EnvVars(); + List paramActions = item.getActions(ParametersAction.class); + for (ParametersAction action : paramActions) { + if (action == null) continue; + for (ParameterValue p : action.getParameters()) { + if (p == null) continue; + Object value = p.getValue(); + if (value != null) { + env.put(p.getName(), value.toString()); + } + } + } + return env; + } - public static Job getProject(Run build) { - Object p = build.getParent(); - return (Job) p; - } + /** + * Returns {@code true} when the given string contains at least one {@code ${...}} variable + * reference that will be resolved at build time. + */ + @Restricted(NoExternalUse.class) + public static boolean containsVariable(@CheckForNull String value) { + return value != null && VARIABLE_PATTERN.matcher(value).find(); + } - public static LockableResourcesStruct requiredResources( - Job project) { - RequiredResourcesProperty property = null; - EnvVars env = new EnvVars(); + public interface MatrixAssist { + @NonNull + Map getCombination(@NonNull Job project); - if (project instanceof MatrixConfiguration) { - env.putAll(((MatrixConfiguration) project).getCombination()); - project = (Job) project.getParent(); - } + @NonNull + Job getMainProject(@NonNull Job project); + } - property = project.getProperty(RequiredResourcesProperty.class); - if (property != null) - return new LockableResourcesStruct(property, env); + @OptionalExtension(requirePlugins = "matrix-project") + public static final class MatrixImpl implements MatrixAssist { + @Override + public Map getCombination(Job project) { + return project instanceof MatrixConfiguration mc ? mc.getCombination() : Map.of(); + } - return null; - } + @Override + public Job getMainProject(Job project) { + return project instanceof MatrixConfiguration mc ? mc.getParent() : project; + } + } } diff --git a/src/main/java/org/jenkins/plugins/lockableresources/remote/RemoteAcquireState.java b/src/main/java/org/jenkins/plugins/lockableresources/remote/RemoteAcquireState.java new file mode 100644 index 000000000..d48d0a628 --- /dev/null +++ b/src/main/java/org/jenkins/plugins/lockableresources/remote/RemoteAcquireState.java @@ -0,0 +1,32 @@ +/* + * The MIT License + * + * See the "LICENSE.txt" file for full copyright and license information. + */ +package org.jenkins.plugins.lockableresources.remote; + +import java.util.Locale; + +/** + * Acquisition states returned by remote /acquire/{requestId} endpoint. + */ +public enum RemoteAcquireState { + QUEUED, + ACQUIRED, + SKIPPED, + FAILED, + CANCELLED, + EXPIRED, + UNKNOWN; + + public static RemoteAcquireState fromString(String state) { + if (state == null || state.isEmpty()) { + return UNKNOWN; + } + try { + return valueOf(state.trim().toUpperCase(Locale.ENGLISH)); + } catch (IllegalArgumentException ex) { + return UNKNOWN; + } + } +} diff --git a/src/main/java/org/jenkins/plugins/lockableresources/remote/RemoteAcquireStatus.java b/src/main/java/org/jenkins/plugins/lockableresources/remote/RemoteAcquireStatus.java new file mode 100644 index 000000000..1eb47a9ed --- /dev/null +++ b/src/main/java/org/jenkins/plugins/lockableresources/remote/RemoteAcquireStatus.java @@ -0,0 +1,59 @@ +/* + * The MIT License + * + * See the "LICENSE.txt" file for full copyright and license information. + */ +package org.jenkins.plugins.lockableresources.remote; + +import edu.umd.cs.findbugs.annotations.CheckForNull; +import java.util.Map; + +/** + * Parsed status of a remote acquire request. + */ +public class RemoteAcquireStatus { + + private final String lockId; + private final RemoteAcquireState state; + private final String errorCode; + private final String message; + + @CheckForNull + private final Map lockEnvVars; + + public RemoteAcquireStatus( + String lockId, + RemoteAcquireState state, + String errorCode, + String message, + @CheckForNull Map lockEnvVars) { + this.lockId = lockId; + this.state = state != null ? state : RemoteAcquireState.UNKNOWN; + this.errorCode = errorCode; + this.message = message; + this.lockEnvVars = lockEnvVars; + } + + public String getLockId() { + return lockId; + } + + public RemoteAcquireState getState() { + return state; + } + + @CheckForNull + public String getErrorCode() { + return errorCode; + } + + @CheckForNull + public String getMessage() { + return message; + } + + @CheckForNull + public Map getLockEnvVars() { + return lockEnvVars; + } +} diff --git a/src/main/java/org/jenkins/plugins/lockableresources/remote/RemoteApiClient.java b/src/main/java/org/jenkins/plugins/lockableresources/remote/RemoteApiClient.java new file mode 100644 index 000000000..bf2a82f58 --- /dev/null +++ b/src/main/java/org/jenkins/plugins/lockableresources/remote/RemoteApiClient.java @@ -0,0 +1,367 @@ +/* + * The MIT License + * + * See the "LICENSE.txt" file for full copyright and license information. + */ +package org.jenkins.plugins.lockableresources.remote; + +import edu.umd.cs.findbugs.annotations.CheckForNull; +import edu.umd.cs.findbugs.annotations.NonNull; +import java.io.IOException; +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.logging.Level; +import java.util.logging.Logger; +import net.sf.json.JSONArray; +import net.sf.json.JSONObject; +import org.jenkins.plugins.lockableresources.RemoteConnection; +import org.kohsuke.accmod.Restricted; +import org.kohsuke.accmod.restrictions.NoExternalUse; + +/** + * Thin HTTP client for the remote lock API. + */ +@Restricted(NoExternalUse.class) +public class RemoteApiClient { + + private static final Logger LOGGER = Logger.getLogger(RemoteApiClient.class.getName()); + private static final int MAX_LOGGED_BODY_CHARS = 512; + + private final HttpClient httpClient; + private final Duration requestTimeout; + + public RemoteApiClient() { + this(Duration.ofSeconds(RemoteClientDefaults.DEFAULT_REQUEST_TIMEOUT_SECONDS)); + } + + public RemoteApiClient(@NonNull Duration requestTimeout) { + this.httpClient = HttpClient.newBuilder().connectTimeout(requestTimeout).build(); + this.requestTimeout = requestTimeout; + } + + /** + * POST /acquire and return accepted lockId. + */ + @NonNull + public String enqueueAcquire( + @NonNull RemoteConnection remote, + @NonNull String authorizationHeader, + @NonNull RemoteLockRequest lockRequest, + int heartbeatIntervalSeconds, + @CheckForNull String clientId) + throws RemoteApiException { + JSONObject requestBody = new JSONObject(); + requestBody.put("lockRequest", buildLockRequestJson(lockRequest)); + if (heartbeatIntervalSeconds > 0) { + requestBody.put("heartbeatIntervalSeconds", heartbeatIntervalSeconds); + } + if (clientId != null && !clientId.isEmpty()) { + requestBody.put("clientId", clientId); + } + + HttpRequest.Builder requestBuilder = HttpRequest.newBuilder(resolve(remote, "/acquire/")) + .header("Accept", "application/json") + .header("Content-Type", "application/json") + .timeout(requestTimeout) + .POST(HttpRequest.BodyPublishers.ofString(requestBody.toString())); + + applyAuthorizationHeader(requestBuilder, authorizationHeader); + HttpRequest request = requestBuilder.build(); + + DecodedJsonResponse decoded = sendAndDecodeJson(remote.getServerId(), "POST", "/acquire/", request); + JSONObject response = decoded.body; + String lockId = extractLockId(response, null); + if (lockId == null || lockId.isEmpty()) { + throw new RemoteApiException( + "Remote acquire response did not contain lockId", + decoded.httpStatus, + remote.getServerId(), + "INVALID_RESPONSE"); + } + return lockId; + } + + private JSONObject buildLockRequestJson(RemoteLockRequest lr) { + JSONObject json = new JSONObject(); + if (lr.getResource() != null) { + json.put("resource", lr.getResource()); + } + if (lr.getLabel() != null) { + json.put("label", lr.getLabel()); + } + if (lr.getQuantity() > 0) { + json.put("quantity", lr.getQuantity()); + } + if (lr.getVariable() != null) { + json.put("variable", lr.getVariable()); + } + json.put("inversePrecedence", lr.isInversePrecedence()); + json.put("resourceSelectStrategy", lr.getResourceSelectStrategy()); + json.put("skipIfLocked", lr.isSkipIfLocked()); + List extra = lr.getExtra(); + if (extra != null && !extra.isEmpty()) { + JSONArray extraJson = new JSONArray(); + for (RemoteLockRequest.ExtraResource r : extra) { + JSONObject rJson = new JSONObject(); + if (r.getResource() != null) rJson.put("resource", r.getResource()); + if (r.getLabel() != null) rJson.put("label", r.getLabel()); + if (r.getQuantity() > 0) rJson.put("quantity", r.getQuantity()); + extraJson.add(rJson); + } + json.put("extra", extraJson); + } + if (lr.getPriority() > 0) { + json.put("priority", lr.getPriority()); + } + if (lr.getTimeoutForAllocateResource() > 0) { + json.put("timeoutForAllocateResource", lr.getTimeoutForAllocateResource()); + } + json.put("timeoutUnit", lr.getTimeoutUnit()); + if (lr.getReason() != null) { + json.put("reason", lr.getReason()); + } + return json; + } + + /** + * GET /acquire/{requestId}. + */ + @NonNull + public RemoteAcquireStatus getAcquireStatus( + @NonNull RemoteConnection remote, @NonNull String authorizationHeader, @NonNull String lockId) + throws RemoteApiException { + String encodedLockId = URLEncoder.encode(lockId, StandardCharsets.UTF_8); + String path = "/acquire/" + encodedLockId + "/"; + HttpRequest.Builder requestBuilder = HttpRequest.newBuilder(resolve(remote, path)) + .header("Accept", "application/json") + .timeout(requestTimeout) + .GET(); + + applyAuthorizationHeader(requestBuilder, authorizationHeader); + HttpRequest request = requestBuilder.build(); + + DecodedJsonResponse decoded = sendAndDecodeJson(remote.getServerId(), "GET", path, request); + JSONObject response = decoded.body; + Map lockEnvVars = null; + JSONObject envVarsJson = response.optJSONObject("lockEnvVars"); + if (envVarsJson != null) { + Map map = new LinkedHashMap<>(); + for (Object key : envVarsJson.keySet()) { + map.put(String.valueOf(key), envVarsJson.optString(String.valueOf(key))); + } + lockEnvVars = Collections.unmodifiableMap(map); + } + return new RemoteAcquireStatus( + extractLockId(response, lockId), + RemoteAcquireState.fromString(response.optString("state", null)), + response.optString("errorCode", null), + response.optString("message", null), + lockEnvVars); + } + + /** + * POST /lease/{lockId}/heartbeat. + */ + public void heartbeatLease( + @NonNull RemoteConnection remote, @NonNull String authorizationHeader, @NonNull String lockId) + throws RemoteApiException { + String encodedLockId = URLEncoder.encode(lockId, StandardCharsets.UTF_8); + String path = "/lease/" + encodedLockId + "/heartbeat"; + HttpRequest.Builder requestBuilder = HttpRequest.newBuilder(resolve(remote, path)) + .header("Accept", "application/json") + .timeout(requestTimeout) + .POST(HttpRequest.BodyPublishers.noBody()); + + applyAuthorizationHeader(requestBuilder, authorizationHeader); + HttpRequest request = requestBuilder.build(); + send(remote.getServerId(), "POST", path, request); + } + + /** + * POST /lease/{lockId}/release. + */ + public void releaseLease( + @NonNull RemoteConnection remote, @NonNull String authorizationHeader, @NonNull String lockId) + throws RemoteApiException { + String encodedLockId = URLEncoder.encode(lockId, StandardCharsets.UTF_8); + String path = "/lease/" + encodedLockId + "/release"; + HttpRequest.Builder requestBuilder = HttpRequest.newBuilder(resolve(remote, path)) + .header("Accept", "application/json") + .timeout(requestTimeout) + .POST(HttpRequest.BodyPublishers.noBody()); + + applyAuthorizationHeader(requestBuilder, authorizationHeader); + HttpRequest request = requestBuilder.build(); + send(remote.getServerId(), "POST", path, request); + } + + private DecodedJsonResponse sendAndDecodeJson(String serverId, String method, String path, HttpRequest request) + throws RemoteApiException { + HttpResponse response = send(serverId, method, path, request); + int status = response.statusCode(); + String body = response.body(); + if (body == null || body.isEmpty()) { + return new DecodedJsonResponse(new JSONObject(), status); + } + try { + return new DecodedJsonResponse(JSONObject.fromObject(body), status); + } catch (RuntimeException ex) { + LOGGER.log( + Level.WARNING, + "Remote JSON parse failure: serverId={0}, method={1}, path={2}, status={3}, bodyPreview={4}", + new Object[] {serverId, method, path, status, abbreviateForLog(body)}); + throw new RemoteApiException( + "Remote response is not valid JSON for " + method + " " + path, status, serverId, "INVALID_JSON"); + } + } + + private String extractLockId(JSONObject response, String fallbackLockId) { + String lockId = response.optString("lockId", null); + if (lockId == null || lockId.isEmpty()) { + lockId = response.optString("requestId", null); + } + if (lockId == null || lockId.isEmpty()) { + lockId = response.optString("leaseId", null); + } + if ((lockId == null || lockId.isEmpty()) && fallbackLockId != null && !fallbackLockId.isEmpty()) { + return fallbackLockId; + } + return lockId; + } + + private HttpResponse send(String serverId, String method, String path, HttpRequest request) + throws RemoteApiException { + try { + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + int status = response.statusCode(); + if (status >= 400) { + String remoteCode = extractRemoteCode(response.body()); + String remoteMessage = extractRemoteMessage(response.body()); + StringBuilder message = new StringBuilder(); + message.append("Remote API request failed: ") + .append(method) + .append(" ") + .append(path) + .append(" returned HTTP ") + .append(status) + .append(" (serverId=") + .append(serverId); + if (remoteCode != null && !remoteCode.isEmpty()) { + message.append(", errorCode=").append(remoteCode); + } + if (remoteMessage != null && !remoteMessage.isEmpty()) { + message.append(", message=").append(remoteMessage); + } + message.append(")"); + if (status == 404) { + message.append( + ". Verify the remote base URL/context path and that the target resource or label exists and is exposed by exposeLabel."); + } + throw new RemoteApiException(message.toString(), status, serverId, remoteCode); + } + return response; + } catch (RemoteApiException ex) { + throw ex; + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + throw new RemoteApiException("Remote API request interrupted: " + method + " " + path, ex, serverId); + } catch (IOException ex) { + LOGGER.log( + Level.WARNING, + "Remote API communication failure (fail-closed): serverId={0}, method={1}, path={2}, message={3}", + new Object[] {serverId, method, path, ex.getMessage()}); + throw new RemoteApiException("Remote API communication failure: " + method + " " + path, ex, serverId); + } + } + + private String extractRemoteMessage(String responseBody) { + if (responseBody == null || responseBody.isEmpty()) { + return null; + } + try { + JSONObject json = JSONObject.fromObject(responseBody); + String message = json.optString("message", null); + if (message == null || message.isEmpty()) { + return null; + } + return abbreviateForLog(message); + } catch (RuntimeException ex) { + return null; + } + } + + private String extractRemoteCode(String responseBody) { + if (responseBody == null || responseBody.isEmpty()) { + return null; + } + try { + JSONObject json = JSONObject.fromObject(responseBody); + String code = json.optString("errorCode", null); + if (code == null || code.isEmpty()) { + code = json.optString("code", null); + } + return code; + } catch (RuntimeException ex) { + return null; + } + } + + private URI resolve(RemoteConnection remote, String path) throws RemoteApiException { + String baseUrl = remote.getUrl(); + if (baseUrl == null || baseUrl.trim().isEmpty()) { + throw new RemoteApiException( + "Remote base URL is empty for serverId=" + remote.getServerId(), + -1, + remote.getServerId(), + "INVALID_CONFIGURATION"); + } + try { + String normalized = baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl; + return URI.create(normalized + RemoteClientDefaults.REMOTE_API_BASE_PATH + path); + } catch (IllegalArgumentException ex) { + throw new RemoteApiException( + "Remote base URL is invalid for serverId=" + remote.getServerId() + ": " + baseUrl, + -1, + remote.getServerId(), + "INVALID_CONFIGURATION"); + } + } + + private static String abbreviateForLog(String text) { + if (text == null) { + return ""; + } + String oneLine = text.replace('\n', ' ').replace('\r', ' '); + if (oneLine.length() <= MAX_LOGGED_BODY_CHARS) { + return oneLine; + } + return oneLine.substring(0, MAX_LOGGED_BODY_CHARS) + + String.format(Locale.ENGLISH, "...(truncated, total=%d chars)", oneLine.length()); + } + + private void applyAuthorizationHeader(HttpRequest.Builder requestBuilder, String authorizationHeader) { + if (authorizationHeader != null && !authorizationHeader.trim().isEmpty()) { + requestBuilder.header("Authorization", authorizationHeader.trim()); + } + } + + private static class DecodedJsonResponse { + private final JSONObject body; + private final int httpStatus; + + private DecodedJsonResponse(JSONObject body, int httpStatus) { + this.body = body; + this.httpStatus = httpStatus; + } + } +} diff --git a/src/main/java/org/jenkins/plugins/lockableresources/remote/RemoteApiException.java b/src/main/java/org/jenkins/plugins/lockableresources/remote/RemoteApiException.java new file mode 100644 index 000000000..3938bf535 --- /dev/null +++ b/src/main/java/org/jenkins/plugins/lockableresources/remote/RemoteApiException.java @@ -0,0 +1,46 @@ +/* + * The MIT License + * + * See the "LICENSE.txt" file for full copyright and license information. + */ +package org.jenkins.plugins.lockableresources.remote; + +import edu.umd.cs.findbugs.annotations.CheckForNull; +import java.io.IOException; + +/** + * Exception from remote API communication. + */ +public class RemoteApiException extends IOException { + + private final int httpStatus; + private final String serverId; + private final String remoteCode; + + public RemoteApiException(String message, int httpStatus, String serverId, String remoteCode) { + super(message); + this.httpStatus = httpStatus; + this.serverId = serverId; + this.remoteCode = remoteCode; + } + + public RemoteApiException(String message, Throwable cause, String serverId) { + super(message, cause); + this.httpStatus = -1; + this.serverId = serverId; + this.remoteCode = null; + } + + public int getHttpStatus() { + return httpStatus; + } + + public String getServerId() { + return serverId; + } + + @CheckForNull + public String getRemoteCode() { + return remoteCode; + } +} diff --git a/src/main/java/org/jenkins/plugins/lockableresources/remote/RemoteClientDefaults.java b/src/main/java/org/jenkins/plugins/lockableresources/remote/RemoteClientDefaults.java new file mode 100644 index 000000000..7840e735a --- /dev/null +++ b/src/main/java/org/jenkins/plugins/lockableresources/remote/RemoteClientDefaults.java @@ -0,0 +1,24 @@ +/* + * The MIT License + * + * See the "LICENSE.txt" file for full copyright and license information. + */ +package org.jenkins.plugins.lockableresources.remote; + +import org.kohsuke.accmod.Restricted; +import org.kohsuke.accmod.restrictions.NoExternalUse; + +/** + * Internal defaults for remote lock client behavior. + */ +@Restricted(NoExternalUse.class) +public final class RemoteClientDefaults { + + public static final String REMOTE_API_BASE_PATH = "/lockable-resources/remote/v1"; + + public static final int DEFAULT_POLL_INTERVAL_SECONDS = 3; + public static final int DEFAULT_HEARTBEAT_INTERVAL_SECONDS = 10; + public static final int DEFAULT_REQUEST_TIMEOUT_SECONDS = 5; + + private RemoteClientDefaults() {} +} diff --git a/src/main/java/org/jenkins/plugins/lockableresources/remote/RemoteCredentials.java b/src/main/java/org/jenkins/plugins/lockableresources/remote/RemoteCredentials.java new file mode 100644 index 000000000..26f03d8ae --- /dev/null +++ b/src/main/java/org/jenkins/plugins/lockableresources/remote/RemoteCredentials.java @@ -0,0 +1,84 @@ +/* + * The MIT License + * + * See the "LICENSE.txt" file for full copyright and license information. + */ +package org.jenkins.plugins.lockableresources.remote; + +import com.cloudbees.plugins.credentials.CredentialsProvider; +import com.cloudbees.plugins.credentials.SystemCredentialsProvider; +import com.cloudbees.plugins.credentials.common.StandardUsernamePasswordCredentials; +import com.cloudbees.plugins.credentials.domains.Domain; +import com.cloudbees.plugins.credentials.domains.DomainRequirement; +import hudson.AbortException; +import hudson.model.Run; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.Collections; +import java.util.logging.Level; +import java.util.logging.Logger; +import org.jenkins.plugins.lockableresources.RemoteConnection; +import org.kohsuke.accmod.Restricted; +import org.kohsuke.accmod.restrictions.NoExternalUse; + +/** + * Resolves the HTTP {@code Authorization} header for a remote connection from its configured + * username/password credentials (Basic auth). Credentials are looked up first in the {@link Run} context + * (so per-folder credentials work), then in the system (global) store. + */ +@Restricted(NoExternalUse.class) +public final class RemoteCredentials { + + private static final Logger LOGGER = Logger.getLogger(RemoteCredentials.class.getName()); + + private RemoteCredentials() {} + + /** + * @return a {@code "Basic "} header value, or {@code ""} when the connection has no + * {@code credentialsId}. Fails the build (via {@link AbortException}) when the referenced + * credentials cannot be found. + */ + public static String basicAuthHeader(RemoteConnection remote, Run run) throws AbortException { + String credentialsId = remote.getCredentialsId(); + if (credentialsId == null || credentialsId.trim().isEmpty()) { + return ""; + } + + String normalizedCredentialsId = credentialsId.trim(); + StandardUsernamePasswordCredentials credentials = null; + try { + if (run != null) { + credentials = CredentialsProvider.findCredentialById( + normalizedCredentialsId, + StandardUsernamePasswordCredentials.class, + run, + Collections.emptyList()); + } + } catch (Exception ex) { + LOGGER.log( + Level.FINE, + "Unable to resolve credentials by Run context for serverId={0}, credentialsId={1}: {2}", + new Object[] {remote.getServerId(), normalizedCredentialsId, ex.getMessage()}); + } + if (credentials == null) { + credentials = + SystemCredentialsProvider.getInstance() + .getDomainCredentialsMap() + .getOrDefault(Domain.global(), Collections.emptyList()) + .stream() + .filter(StandardUsernamePasswordCredentials.class::isInstance) + .map(StandardUsernamePasswordCredentials.class::cast) + .filter(candidate -> normalizedCredentialsId.equals(candidate.getId())) + .findFirst() + .orElse(null); + } + if (credentials == null) { + throw new AbortException("Remote credentials not found for serverId=" + remote.getServerId() + + ", credentialsId=" + normalizedCredentialsId); + } + + String basicToken = + credentials.getUsername() + ':' + credentials.getPassword().getPlainText(); + return "Basic " + Base64.getEncoder().encodeToString(basicToken.getBytes(StandardCharsets.UTF_8)); + } +} diff --git a/src/main/java/org/jenkins/plugins/lockableresources/remote/RemoteLockManager.java b/src/main/java/org/jenkins/plugins/lockableresources/remote/RemoteLockManager.java new file mode 100644 index 000000000..1f2a00ad0 --- /dev/null +++ b/src/main/java/org/jenkins/plugins/lockableresources/remote/RemoteLockManager.java @@ -0,0 +1,241 @@ +/* + * The MIT License + * + * See the "LICENSE.txt" file for full copyright and license information. + */ +package org.jenkins.plugins.lockableresources.remote; + +import edu.umd.cs.findbugs.annotations.CheckForNull; +import edu.umd.cs.findbugs.annotations.NonNull; +import hudson.Extension; +import hudson.model.PeriodicWork; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; +import java.util.logging.Level; +import java.util.logging.Logger; +import jenkins.model.Jenkins; +import org.jenkins.plugins.lockableresources.LockableResource; +import org.jenkins.plugins.lockableresources.LockableResourcesManager; +import org.jenkins.plugins.lockableresources.queue.LockableResourcesStruct; +import org.kohsuke.accmod.Restricted; +import org.kohsuke.accmod.restrictions.NoExternalUse; + +/** + * Server-side manager for remote lock records. + * + *

Runs a 1-second tick loop (via {@link PeriodicWork}) that: + *

    + *
  • Attempts to promote QUEUED records to ACQUIRED when their resource(s) become free.
  • + *
  • Marks ACQUIRED records as STALE when heartbeat times out.
  • + *
  • Cleans up terminal (SKIPPED/FAILED) records after a TTL.
  • + *
+ * + *

STALE locks are held until an administrator explicitly releases them. + * In-memory state is lost on Jenkins restart; all transient remote locks are + * automatically freed because {@link LockableResource#remoteLockedBy} is transient. + */ +@Restricted(NoExternalUse.class) +@Extension +public class RemoteLockManager extends PeriodicWork { + + private static final Logger LOGGER = Logger.getLogger(RemoteLockManager.class.getName()); + + /** + * Time after which an ACQUIRED record with no heartbeat is marked STALE. + * = max(heartbeatInterval x 6, 60) seconds. + */ + static final long STALE_THRESHOLD_MS = + TimeUnit.SECONDS.toMillis(Math.max(RemoteClientDefaults.DEFAULT_HEARTBEAT_INTERVAL_SECONDS * 6, 60)); + + /** Terminal records (SKIPPED/FAILED) are removed from the map after this TTL. */ + private static final long TERMINAL_TTL_MS = TimeUnit.SECONDS.toMillis(120); + + private final ConcurrentHashMap records = new ConcurrentHashMap<>(); + + // ----------------------------------------------------------------------- + public static RemoteLockManager get() { + return Jenkins.get().getExtensionList(RemoteLockManager.class).get(0); + } + + // ----------------------------------------------------------------------- + @Override + public long getRecurrencePeriod() { + return 1000L; // 1 second + } + + // ----------------------------------------------------------------------- + @Override + protected void doRun() { + // Queue promotion is now handled by LRM.proceedNextContext() via the unified queue bridge. + // Only periodic stale detection and terminal-record TTL cleanup remain here. + maybeScanStale(); + } + + // ----------------------------------------------------------------------- + /** + * Enqueues a new remote acquire request. + * + *

Immediately attempts to lock the resource(s). If successful the returned record + * has state {@link RemoteLockState#ACQUIRED}. If the resource is busy and + * {@code skipIfLocked} is true, state is {@link RemoteLockState#SKIPPED}. + * Otherwise state is {@link RemoteLockState#QUEUED} and the entry is registered in the LRM + * queue so it participates in unified priority dispatch with local pipeline lock() steps. + */ + public RemoteLockRecord enqueue(@NonNull RemoteLockRequest lockRequest, @CheckForNull String clientId) { + String lockId = UUID.randomUUID().toString(); + RemoteLockRecord record = new RemoteLockRecord(lockId, lockRequest, clientId); + + LockableResourcesManager lrm = LockableResourcesManager.get(); + RemoteResolver resolver = new RemoteResolver(lrm); + synchronized (LockableResourcesManager.syncResources) { + // Admission: a selector referencing something this client can't lock (unknown/unexposed) is + // rejected up front (terminal; the caller maps UNKNOWN_* to HTTP 404). This avoids creating + // ephemeral resources and avoids queueing forever for something that will never be lockable. + String errorCode = resolver.validateRemoteSelectors(lockRequest); + if (errorCode != null) { + record.markFailed(errorCode); + records.put(lockId, record); + LOGGER.fine("Remote acquire rejected: lockId=" + lockId + " errorCode=" + errorCode); + return record; + } + // Resolve through the SAME canonical path local lock() uses (no re-implementation of lock() + // semantics), with the exposeLabel set as candidate filter. extra / label / quantity(0=all) / + // resourceSelectStrategy / property env vars all come from the canonical path. A request whose + // (exposed, existing) targets are merely busy stays QUEUED, exactly like local. + List structs = resolver.toRemoteStructs(lockRequest); + if (structs.isEmpty()) { + record.markFailed("MISSING_TARGET"); + } else { + List available = resolver.availableForRemote(structs, lockRequest); + if (available != null && !available.isEmpty()) { + lrm.lockForRemote(available, lockId); + record.markAcquired( + LockableResourcesManager.getResourcesNames(available), + RemoteResolver.remoteLockEnvVars(lockRequest.getVariable(), available)); + } else if (lockRequest.isSkipIfLocked()) { + record.markSkipped(); + } else { + // Busy - register in the LRM unified queue for priority dispatch with local lock() steps. + RemoteQueueEntry entry = new RemoteQueueEntry( + record, + structs, + lockRequest.getPriority(), + lockRequest.getTimeoutForAllocateResource(), + lockRequest.getTimeoutUnit()); + lrm.queueRemote(entry); + } + } + } + + records.put(lockId, record); + LOGGER.fine( + "Remote acquire enqueued: lockId=" + lockId + " state=" + record.getState() + " clientId=" + clientId); + return record; + } + + // ----------------------------------------------------------------------- + /** + * Returns the record for the given lockId, or {@code null} if not found. + */ + @CheckForNull + public RemoteLockRecord find(String lockId) { + return records.get(lockId); + } + + // ----------------------------------------------------------------------- + /** + * Updates the heartbeat timestamp for an ACQUIRED lock. + * + * @return {@code false} if the lockId is unknown or not in ACQUIRED state. + */ + public boolean heartbeat(String lockId) { + RemoteLockRecord record = records.get(lockId); + if (record == null) { + return false; + } + if (record.getState() != RemoteLockState.ACQUIRED) { + return false; + } + record.heartbeat(); + return true; + } + + // ----------------------------------------------------------------------- + /** + * Releases a lock, freeing the underlying resource(s). Idempotent. + */ + public void release(String lockId) { + RemoteLockRecord record = records.remove(lockId); + if (record == null) { + return; + } + LockableResourcesManager lrm = LockableResourcesManager.get(); + + // Decide and mutate queue state atomically under syncResources so a concurrent queue + // promotion (proceedNextContext -> proceedRemoteEntry, which checks entry.isValid() == + // QUEUED) cannot acquire resources for an already-released record. Without this guard a + // QUEUED record released at the same instant its resources free up would leave an orphan + // remote lock (resource pinned, record gone from the map, unrecoverable until restart). + List namesToUnlock = null; + synchronized (LockableResourcesManager.syncResources) { + RemoteLockState state = record.getState(); + if (state == RemoteLockState.ACQUIRED || state == RemoteLockState.STALE) { + List names = record.getAcquiredResourceNames(); + if (names != null && !names.isEmpty()) { + namesToUnlock = names; + } + } else if (state == RemoteLockState.QUEUED) { + // Terminal-mark before unqueue so a not-yet-run promotion is excluded. + record.markFailed("RELEASED"); + lrm.unqueueRemote(lockId); + } + } + + // unlockRemoteResources() / scheduleQueueMaintenance() must run OUTSIDE syncResources + // (they touch the Jenkins Queue lock). + if (namesToUnlock != null) { + // frees resources, wakes local+remote waiters, and schedules maintenance + lrm.unlockRemoteResources(namesToUnlock, lockId); + } else { + lrm.scheduleQueueMaintenance(); + } + LOGGER.fine("Remote lock released: lockId=" + lockId + " resources=" + record.getAcquiredResourceNames()); + } + + // ----------------------------------------------------------------------- + /** + * Marks ACQUIRED records STALE when heartbeat has been missing too long, + * and cleans up terminal records (SKIPPED/FAILED) after their TTL. + * QUEUED records are expired by the unified queue's own timeout + * ({@code RemoteQueueEntry} deadline = {@code timeoutForAllocateResource}), not here. + */ + private void maybeScanStale() { + long now = System.currentTimeMillis(); + for (RemoteLockRecord record : records.values()) { + RemoteLockState state = record.getState(); + if (state == RemoteLockState.ACQUIRED) { + if (now - record.getLastHeartbeatAt() > STALE_THRESHOLD_MS) { + record.markStale(); + LOGGER.log( + Level.WARNING, + "Remote lock STALE: lockId={0} resources={1} lastHeartbeatAge={2}ms", + new Object[] { + record.getLockId(), record.getAcquiredResourceNames(), now - record.getLastHeartbeatAt() + }); + } + } else if (state == RemoteLockState.SKIPPED || state == RemoteLockState.FAILED) { + // Measure retention from when the record became terminal, NOT from enqueue: a record that + // times out after a long queue wait (timeoutForAllocateResource > TERMINAL_TTL_MS) only + // becomes FAILED at the deadline, so an enqueue-based TTL would already be exceeded at that + // instant and the record would be evicted before a polling client can read the terminal + // state - surfacing a legitimate LOCK_WAIT_TIMEOUT as a 404. Terminal-time TTL keeps the + // FAILED/SKIPPED record observable for the full TTL regardless of how long the wait was. + if (now - record.getTerminalAt() > TERMINAL_TTL_MS) { + records.remove(record.getLockId()); + } + } + } + } +} diff --git a/src/main/java/org/jenkins/plugins/lockableresources/remote/RemoteLockRecord.java b/src/main/java/org/jenkins/plugins/lockableresources/remote/RemoteLockRecord.java new file mode 100644 index 000000000..95a8bd1be --- /dev/null +++ b/src/main/java/org/jenkins/plugins/lockableresources/remote/RemoteLockRecord.java @@ -0,0 +1,157 @@ +/* + * The MIT License + * + * See the "LICENSE.txt" file for full copyright and license information. + */ +package org.jenkins.plugins.lockableresources.remote; + +import edu.umd.cs.findbugs.annotations.CheckForNull; +import edu.umd.cs.findbugs.annotations.NonNull; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import org.kohsuke.accmod.Restricted; +import org.kohsuke.accmod.restrictions.NoExternalUse; + +/** + * Server-side record tracking a remote lock request lifecycle. + * + *

All state transitions are performed by {@link RemoteLockManager}'s single-threaded + * tick executor, except for {@link #heartbeat()} which is written by the HTTP thread. + * Fields are {@code volatile} to ensure visibility across threads. + */ +@Restricted(NoExternalUse.class) +public final class RemoteLockRecord { + + private final String lockId; + + @NonNull + private final RemoteLockRequest lockRequest; + /** Caller-supplied client identifier (e.g. the A-side Jenkins root URL). May be null if not provided. */ + @CheckForNull + private final String clientId; + + private volatile RemoteLockState state; + private final long enqueuedAt; + private volatile long acquiredAt; + private volatile long lastHeartbeatAt; + + /** + * Timestamp when the record entered a terminal state (SKIPPED/FAILED); 0 while non-terminal. + * The terminal-record TTL cleanup measures retention from this instant, not from {@link #enqueuedAt}, + * so a record that becomes terminal only after a long queue wait (e.g. timeoutForAllocateResource + * > the TTL) is still retained for the full TTL and can be observed by a polling client. + */ + private volatile long terminalAt; + + @CheckForNull + private volatile String errorCode; + + @CheckForNull + private volatile List acquiredResourceNames; + + @CheckForNull + private volatile Map lockEnvVars; + + RemoteLockRecord(@NonNull String lockId, @NonNull RemoteLockRequest lockRequest, @CheckForNull String clientId) { + this.lockId = lockId; + this.lockRequest = lockRequest; + this.clientId = clientId; + this.state = RemoteLockState.QUEUED; + long now = System.currentTimeMillis(); + this.enqueuedAt = now; + this.lastHeartbeatAt = now; + } + + public String getLockId() { + return lockId; + } + + @NonNull + public RemoteLockRequest getLockRequest() { + return lockRequest; + } + + /** + * Returns the first acquired resource name, or the requested resource name if not yet acquired. + * Returns {@code null} for label-only requests that have not yet been acquired. + */ + @CheckForNull + public String getResourceName() { + List names = acquiredResourceNames; + if (names != null && !names.isEmpty()) { + return names.get(0); + } + return lockRequest.getResource(); + } + + @CheckForNull + public List getAcquiredResourceNames() { + return acquiredResourceNames; + } + + @CheckForNull + public Map getLockEnvVars() { + return lockEnvVars; + } + + @CheckForNull + public String getClientId() { + return clientId; + } + + public RemoteLockState getState() { + return state; + } + + public long getEnqueuedAt() { + return enqueuedAt; + } + + /** Instant the record became terminal (SKIPPED/FAILED), or 0 if still non-terminal. */ + public long getTerminalAt() { + return terminalAt; + } + + public long getAcquiredAt() { + return acquiredAt; + } + + public long getLastHeartbeatAt() { + return lastHeartbeatAt; + } + + @CheckForNull + public String getErrorCode() { + return errorCode; + } + + void markAcquired(@NonNull List names, @CheckForNull Map lockEnvVars) { + this.acquiredResourceNames = Collections.unmodifiableList(new ArrayList<>(names)); + this.lockEnvVars = lockEnvVars; + long now = System.currentTimeMillis(); + this.acquiredAt = now; + this.lastHeartbeatAt = now; + this.state = RemoteLockState.ACQUIRED; + } + + void markSkipped() { + this.terminalAt = System.currentTimeMillis(); + this.state = RemoteLockState.SKIPPED; + } + + void markFailed(String code) { + this.errorCode = code; + this.terminalAt = System.currentTimeMillis(); + this.state = RemoteLockState.FAILED; + } + + void markStale() { + this.state = RemoteLockState.STALE; + } + + void heartbeat() { + this.lastHeartbeatAt = System.currentTimeMillis(); + } +} diff --git a/src/main/java/org/jenkins/plugins/lockableresources/remote/RemoteLockRequest.java b/src/main/java/org/jenkins/plugins/lockableresources/remote/RemoteLockRequest.java new file mode 100644 index 000000000..0d6dff95d --- /dev/null +++ b/src/main/java/org/jenkins/plugins/lockableresources/remote/RemoteLockRequest.java @@ -0,0 +1,197 @@ +/* + * The MIT License + * + * See the "LICENSE.txt" file for full copyright and license information. + */ +package org.jenkins.plugins.lockableresources.remote; + +import edu.umd.cs.findbugs.annotations.CheckForNull; +import edu.umd.cs.findbugs.annotations.NonNull; +import java.util.List; +import java.util.stream.Collectors; +import org.jenkins.plugins.lockableresources.LockStep; +import org.kohsuke.accmod.Restricted; +import org.kohsuke.accmod.restrictions.NoExternalUse; + +/** + * Lock-semantics payload for the remote lock API. + * Corresponds to the {@code lockRequest} field in {@code POST /acquire}. + * + *

Routing fields ({@code serverId}, {@code forcedServerId}) are intentionally excluded - + * they are transport/configuration concerns, not lock semantics. + */ +@Restricted(NoExternalUse.class) +public final class RemoteLockRequest { + + @CheckForNull + private final String resource; + + @CheckForNull + private final String label; + + private final int quantity; + + @CheckForNull + private final String variable; + + private final boolean inversePrecedence; + + @NonNull + private final String resourceSelectStrategy; + + private final boolean skipIfLocked; + + @CheckForNull + private final List extra; + + private final int priority; + private final long timeoutForAllocateResource; + + @NonNull + private final String timeoutUnit; + + @CheckForNull + private final String reason; + + public RemoteLockRequest( + @CheckForNull String resource, + @CheckForNull String label, + int quantity, + @CheckForNull String variable, + boolean inversePrecedence, + @NonNull String resourceSelectStrategy, + boolean skipIfLocked, + @CheckForNull List extra, + int priority, + long timeoutForAllocateResource, + @NonNull String timeoutUnit, + @CheckForNull String reason) { + this.resource = resource; + this.label = label; + this.quantity = quantity; + this.variable = variable; + this.inversePrecedence = inversePrecedence; + this.resourceSelectStrategy = resourceSelectStrategy; + this.skipIfLocked = skipIfLocked; + this.extra = extra; + this.priority = priority; + this.timeoutForAllocateResource = timeoutForAllocateResource; + this.timeoutUnit = timeoutUnit; + this.reason = reason; + } + + @CheckForNull + public String getResource() { + return resource; + } + + @CheckForNull + public String getLabel() { + return label; + } + + public int getQuantity() { + return quantity; + } + + @CheckForNull + public String getVariable() { + return variable; + } + + public boolean isInversePrecedence() { + return inversePrecedence; + } + + @NonNull + public String getResourceSelectStrategy() { + return resourceSelectStrategy; + } + + public boolean isSkipIfLocked() { + return skipIfLocked; + } + + @CheckForNull + public List getExtra() { + return extra; + } + + public int getPriority() { + return priority; + } + + public long getTimeoutForAllocateResource() { + return timeoutForAllocateResource; + } + + @NonNull + public String getTimeoutUnit() { + return timeoutUnit; + } + + @CheckForNull + public String getReason() { + return reason; + } + + /** + * Builds a {@code RemoteLockRequest} from the DSL lock step. + * {@code serverId} is excluded - it is a routing concern, not part of lock semantics. + */ + @NonNull + public static RemoteLockRequest from(@NonNull LockStep step) { + List extra = null; + if (step.extra != null && !step.extra.isEmpty()) { + extra = step.extra.stream() + .map(r -> new ExtraResource(r.resource, r.label, r.quantity)) + .collect(Collectors.toList()); + } + return new RemoteLockRequest( + step.resource, + step.label, + step.quantity, + step.variable, + step.inversePrecedence, + step.resourceSelectStrategy, + step.skipIfLocked, + extra, + step.priority, + step.timeoutForAllocateResource, + step.timeoutUnit, + step.reason); + } + + // ----------------------------------------------------------------------- + + /** Represents an additional resource in a multi-resource lock request. */ + public static final class ExtraResource { + @CheckForNull + private final String resource; + + @CheckForNull + private final String label; + + private final int quantity; + + public ExtraResource(@CheckForNull String resource, @CheckForNull String label, int quantity) { + this.resource = resource; + this.label = label; + this.quantity = quantity; + } + + @CheckForNull + public String getResource() { + return resource; + } + + @CheckForNull + public String getLabel() { + return label; + } + + public int getQuantity() { + return quantity; + } + } +} diff --git a/src/main/java/org/jenkins/plugins/lockableresources/remote/RemoteLockRouting.java b/src/main/java/org/jenkins/plugins/lockableresources/remote/RemoteLockRouting.java new file mode 100644 index 000000000..d3e856296 --- /dev/null +++ b/src/main/java/org/jenkins/plugins/lockableresources/remote/RemoteLockRouting.java @@ -0,0 +1,84 @@ +/* + * The MIT License + * + * See the "LICENSE.txt" file for full copyright and license information. + */ +package org.jenkins.plugins.lockableresources.remote; + +import hudson.AbortException; +import java.io.PrintStream; +import java.util.logging.Level; +import java.util.logging.Logger; +import org.jenkins.plugins.lockableresources.LockStep; +import org.jenkins.plugins.lockableresources.LockableResourcesManager; +import org.jenkins.plugins.lockableresources.RemoteConnection; +import org.kohsuke.accmod.Restricted; +import org.kohsuke.accmod.restrictions.NoExternalUse; + +/** + * Client-side routing helpers for {@code lock()}: decides whether a step targets a remote server and to + * which {@link RemoteConnection}, and derives the human-readable target. + * + *

    + *
  • Peer mode - {@code lock(..., serverId: 'X')} targets server {@code X} explicitly. + *
  • Delegated mode - a configured {@code forcedServerId} routes every lock to that server + * (and overrides any DSL {@code serverId}, logging an INFO when they differ). + *
+ */ +@Restricted(NoExternalUse.class) +public final class RemoteLockRouting { + + private static final Logger LOGGER = Logger.getLogger(RemoteLockRouting.class.getName()); + + private RemoteLockRouting() {} + + /** A step is a remote request iff it names a {@code serverId} or a {@code forcedServerId} is configured. */ + public static boolean isRemoteRequest(LockStep step, LockableResourcesManager lrm) { + if (step.serverId != null && !step.serverId.trim().isEmpty()) { + return true; + } + String forced = lrm.getForcedServerId(); + return forced != null && !forced.trim().isEmpty(); + } + + /** Resolves the server to target: {@code forcedServerId} wins over the DSL {@code serverId} (delegated mode). */ + public static String effectiveServerId(LockStep step, LockableResourcesManager lrm, PrintStream logger) { + String forced = lrm.getForcedServerId(); + if (forced != null && !forced.trim().isEmpty()) { + if (step.serverId != null + && !step.serverId.trim().isEmpty() + && !step.serverId.trim().equals(forced.trim())) { + LockableResourcesManager.printLogs( + "forcedServerId '" + forced + "' overrides DSL serverId '" + step.serverId + "'", + Level.INFO, + LOGGER, + logger); + } + return forced.trim(); + } + return step.serverId; + } + + /** Looks up the configured {@link RemoteConnection} for {@code serverId}, or fails the build. */ + public static RemoteConnection findConnection(LockableResourcesManager lrm, String serverId) throws AbortException { + RemoteConnection remote = lrm.getRemotesAsMap().get(serverId); + if (remote == null) { + throw new AbortException("Remote connection not found for serverId=" + serverId); + } + return remote; + } + + /** Human-readable lock target (resource name / {@code label:X} / step description) for logs and UI. */ + public static String displayTarget(LockStep step) throws AbortException { + if (step.resource != null && !step.resource.trim().isEmpty()) { + return step.resource.trim(); + } + if (step.label != null && !step.label.trim().isEmpty()) { + return "label:" + step.label.trim(); + } + if (step.extra != null && !step.extra.isEmpty()) { + return step.toString(); + } + throw new AbortException("Remote lock requires at least one of: resource, label, or extra"); + } +} diff --git a/src/main/java/org/jenkins/plugins/lockableresources/remote/RemoteLockSession.java b/src/main/java/org/jenkins/plugins/lockableresources/remote/RemoteLockSession.java new file mode 100644 index 000000000..647fd9319 --- /dev/null +++ b/src/main/java/org/jenkins/plugins/lockableresources/remote/RemoteLockSession.java @@ -0,0 +1,470 @@ +/* + * The MIT License + * + * See the "LICENSE.txt" file for full copyright and license information. + */ +package org.jenkins.plugins.lockableresources.remote; + +import edu.umd.cs.findbugs.annotations.CheckForNull; +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import hudson.AbortException; +import hudson.model.Run; +import hudson.model.TaskListener; +import java.io.PrintStream; +import java.io.Serializable; +import java.util.Collections; +import java.util.Map; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.logging.Level; +import java.util.logging.Logger; +import org.jenkins.plugins.lockableresources.LockStep; +import org.jenkins.plugins.lockableresources.LockableResourcesManager; +import org.jenkins.plugins.lockableresources.RemoteConnection; +import org.jenkins.plugins.lockableresources.actions.LockedResourcesBuildAction; +import org.jenkinsci.plugins.workflow.graph.FlowNode; +import org.jenkinsci.plugins.workflow.steps.StepContext; +import org.jenkinsci.plugins.workflow.support.actions.PauseAction; +import org.kohsuke.accmod.Restricted; +import org.kohsuke.accmod.restrictions.NoExternalUse; + +/** + * Client-side state machine for a remote {@code lock()}: enqueue the acquire request, short-poll the remote + * server for the outcome, heartbeat while the body runs, and release on completion. A serializable + * collaborator of the {@code lock()} step execution. + * + *

The session owns the transport/scheduling state (poll & heartbeat timers, retry budget, the + * persisted {@code serverId}/{@code lockId}/state). Everything that requires the workflow step's own context + * - running the lock body, signalling success/failure - is delegated to a {@link Host} (implemented by + * {@code LockStepExecution}). The session is serialized as a field of the step execution, so it survives a + * controller restart; the timers are {@code transient} and rebuilt by {@link #onResume(Host)}. + */ +@Restricted(NoExternalUse.class) +public final class RemoteLockSession implements Serializable { + + private static final long serialVersionUID = 1L; + + private static final Logger LOGGER = Logger.getLogger(RemoteLockSession.class.getName()); + + private static final int MAX_CONSECUTIVE_POLL_FAILURES = 20; // ~60s at 3s poll interval + + /** + * Step-side integration the session calls back into. Implemented by {@code LockStepExecution}; kept + * minimal so the bulk of the remote flow lives here rather than in the core step class. + */ + public interface Host extends Serializable { + /** The workflow step context (for {@code Run}/{@code FlowNode}/{@code TaskListener}, onSuccess/onFailure). */ + StepContext context(); + + /** The originating {@code lock()} step. */ + LockStep step(); + + /** Runs the lock body; on completion the framework releases the remote lock via {@link #onBodyFinished(Host)}. */ + void runBody(String displayTarget, @CheckForNull Map lockEnvVars, String lockId); + } + + private final AtomicBoolean completionSignaled = new AtomicBoolean(false); + + private transient volatile ScheduledFuture pollTask; + private transient volatile ScheduledFuture heartbeatTask; + + private volatile String serverId; + private volatile String lockId; + private volatile RemoteAcquireState lastState = RemoteAcquireState.UNKNOWN; + private volatile boolean bodyStarted; + private volatile int consecutivePollFailures; + + /** @return the remote lockId once enqueued (used by the step for resume detection); may be {@code null}. */ + @CheckForNull + public String getLockId() { + return lockId; + } + + /** @return the target serverId once the acquire has been enqueued; may be {@code null}. */ + @CheckForNull + public String getServerId() { + return serverId; + } + + /** Fails the session (fail-closed; no release) - used by the host when running the body throws. */ + public void fail(Host host, Throwable cause) { + finishFailure(host, cause); + } + + // --------------------------------------------------------------------------- + public boolean start(Host host) throws Exception { + LockStep step = host.step(); + StepContext context = host.context(); + PrintStream logger = context.get(TaskListener.class).getLogger(); + Run run = context.get(Run.class); + LockableResourcesManager lrm = LockableResourcesManager.get(); + + String displayTarget = RemoteLockRouting.displayTarget(step); + String effectiveServerId = RemoteLockRouting.effectiveServerId(step, lrm, logger); + RemoteConnection remote = RemoteLockRouting.findConnection(lrm, effectiveServerId); + String authorizationHeader = RemoteCredentials.basicAuthHeader(remote, run); + RemoteApiClient client = new RemoteApiClient(); + RemoteLockRequest lockRequest = RemoteLockRequest.from(step); + + LockableResourcesManager.printLogs( + "Trying to acquire remote lock on [" + + step + + "] (serverId=" + + remote.getServerId() + + ", serverUrl=" + + remote.getUrl() + + ")", + Level.FINE, + LOGGER, + logger); + context.get(FlowNode.class).addAction(new PauseAction("Lock")); + + try { + // Use configured clientId (or root URL as fallback) to identify this Jenkins to the remote server. + String clientId = lrm.getEffectiveClientId(); + String acquiredLockId = client.enqueueAcquire( + remote, + authorizationHeader, + lockRequest, + RemoteClientDefaults.DEFAULT_HEARTBEAT_INTERVAL_SECONDS, + clientId); + this.serverId = remote.getServerId(); + this.lockId = acquiredLockId; + LockableResourcesManager.printLogs( + "Remote acquire enqueued (serverId=" + + remote.getServerId() + + ", serverUrl=" + + remote.getUrl() + + ", lockId=" + + acquiredLockId + + ")", + Level.FINE, + LOGGER, + logger); + startPolling(host, remote, authorizationHeader, client, run, displayTarget); + } catch (RemoteApiException ex) { + logger.println("Remote lock request failed (serverId=" + + remote.getServerId() + + ", serverUrl=" + + remote.getUrl() + + "): " + + ex.getMessage()); + finishFailure(host, ex); + } + return false; + } + + private void startPolling( + Host host, + RemoteConnection remote, + String authorizationHeader, + RemoteApiClient client, + Run run, + String remoteResource) { + cancelPollTask(); + pollTask = jenkins.util.Timer.get() + .scheduleWithFixedDelay( + () -> pollStatus(host, remote, authorizationHeader, client, run, remoteResource), + 0, + RemoteClientDefaults.DEFAULT_POLL_INTERVAL_SECONDS, + TimeUnit.SECONDS); + } + + @SuppressFBWarnings( + value = {"REC_CATCH_EXCEPTION", "VO_VOLATILE_INCREMENT"}, + justification = "Poll loop must not propagate any exception; consecutivePollFailures is mutated only" + + " on the single-threaded poll executor (volatile is for visibility to onResume).") + private void pollStatus( + Host host, + RemoteConnection remote, + String authorizationHeader, + RemoteApiClient client, + Run run, + String remoteResource) { + if (completionSignaled.get()) { + return; + } + + try { + RemoteAcquireStatus status = client.getAcquireStatus(remote, authorizationHeader, lockId); + RemoteAcquireState state = status.getState(); + String statusLockId = status.getLockId(); + + if (state != lastState) { + lastState = state; + LOGGER.log( + Level.FINE, + "Remote acquire state update: serverId={0}, lockId={1}, state={2}", + new Object[] {serverId, statusLockId, state}); + } + + consecutivePollFailures = 0; + switch (state) { + case QUEUED: + return; + case ACQUIRED: + if (bodyStarted) { + return; + } + if (statusLockId == null || statusLockId.isEmpty()) { + finishFailure( + host, + new AbortException( + "Remote acquire returned ACQUIRED without lockId for serverId=" + serverId)); + return; + } + lockId = statusLockId; + bodyStarted = true; + cancelPollTask(); + startHeartbeat(remote, authorizationHeader, client); + host.runBody(remoteResource, status.getLockEnvVars(), statusLockId); + return; + case SKIPPED: + cancelPollTask(); + completionSignaled.set(true); + PauseAction.endCurrentPause(host.context().get(FlowNode.class)); + LockedResourcesBuildAction.addLog( + run, + Collections.singletonList(remoteResource), + "skipped", + host.step().toString()); + host.context().onSuccess(null); + return; + case FAILED: + case EXPIRED: + case UNKNOWN: + finishFailure(host, new AbortException(buildFailureMessage(status))); + return; + case CANCELLED: + // Keep handling CANCELLED for compatibility with remote-side/admin cancellation. + finishFailure( + host, + new InterruptedException( + "Remote acquire was cancelled (serverId=" + serverId + ", lockId=" + lockId + ")")); + return; + default: + finishFailure(host, new AbortException(buildFailureMessage(status))); + } + } catch (Exception ex) { + // Distinguish lockId-not-found (server restart) from transient network failure. + // 404/410 means the server has no record of this lock - irrecoverable. + if (ex instanceof RemoteApiException) { + int httpStatus = ((RemoteApiException) ex).getHttpStatus(); + if (httpStatus == 404 || httpStatus == 410) { + if (!bodyStarted) { + // Still acquiring: the QUEUED record is gone. We hold a lockId (admission passed at + // POST) and skipIfLocked is resolved synchronously at POST (never polled), so a + // never-acquired record vanishing means the server-side allocation wait timed out. + // Normalize to LOCK_WAIT_TIMEOUT - the same outcome as observing a FAILED state - + // instead of mislabeling a legitimate timeout as a transport/communication failure. + LOGGER.log( + Level.WARNING, + "Remote acquire wait ended (HTTP {0}); treating as LOCK_WAIT_TIMEOUT. " + + "serverId={1}, lockId={2}", + new Object[] {httpStatus, serverId, lockId}); + finishFailure( + host, + new AbortException("Remote acquire failed (serverId=" + serverId + ", lockId=" + lockId + + ", state=FAILED, errorCode=LOCK_WAIT_TIMEOUT)")); + return; + } + // An already-ACQUIRED lease vanished (server restart / forced release): irrecoverable. + LOGGER.log( + Level.WARNING, + "Remote lock not found on server (HTTP {0}); server may have restarted. " + + "serverId={1}, lockId={2}", + new Object[] {httpStatus, serverId, lockId}); + finishFailure( + host, + new AbortException( + "Remote lock not found (HTTP " + httpStatus + "), server may have restarted. " + + "serverId=" + serverId + ", lockId=" + lockId)); + return; + } + } + // Transient failure - retry up to threshold before failing the job + consecutivePollFailures++; + if (consecutivePollFailures >= MAX_CONSECUTIVE_POLL_FAILURES) { + LOGGER.log( + Level.WARNING, + "Remote poll failed {0} consecutive times; giving up. serverId={1}, lockId={2}", + new Object[] {consecutivePollFailures, serverId, lockId}); + finishFailure(host, ex); + } else { + LOGGER.log( + Level.WARNING, + "Remote poll failure ({0}/{1}); retrying. serverId={2}, lockId={3}: {4}", + new Object[] { + consecutivePollFailures, MAX_CONSECUTIVE_POLL_FAILURES, serverId, lockId, ex.getMessage() + }); + } + } + } + + private void startHeartbeat(RemoteConnection remote, String authorizationHeader, RemoteApiClient client) { + cancelHeartbeatTask(); + heartbeatTask = jenkins.util.Timer.get() + .scheduleWithFixedDelay( + () -> { + if (completionSignaled.get()) { + return; + } + String currentLockId = lockId; + if (currentLockId == null || currentLockId.isEmpty()) { + return; + } + try { + client.heartbeatLease(remote, authorizationHeader, currentLockId); + } catch (Exception ex) { + // fail-close: server retains the lock; job continues + LOGGER.log( + Level.WARNING, + "Remote heartbeat failed (continuing job; server retains lock): " + + "serverId={0}, lockId={1}: {2}", + new Object[] {serverId, currentLockId, ex.getMessage()}); + } + }, + RemoteClientDefaults.DEFAULT_HEARTBEAT_INTERVAL_SECONDS, + RemoteClientDefaults.DEFAULT_HEARTBEAT_INTERVAL_SECONDS, + TimeUnit.SECONDS); + } + + @SuppressFBWarnings(value = "REC_CATCH_EXCEPTION", justification = "Best-effort remote cleanup should not throw") + private void releaseBestEffort(Host host) { + String currentLockId = lockId; + if (currentLockId == null || currentLockId.isEmpty()) { + return; + } + + try { + LockableResourcesManager lrm = LockableResourcesManager.get(); + RemoteConnection remote = RemoteLockRouting.findConnection(lrm, serverId); + Run run = host.context().get(Run.class); + String authorizationHeader = RemoteCredentials.basicAuthHeader(remote, run); + new RemoteApiClient().releaseLease(remote, authorizationHeader, currentLockId); + LOGGER.log( + Level.FINE, "Remote lock released: serverId={0}, lockId={1}", new Object[] {serverId, currentLockId + }); + } catch (Exception ex) { + LOGGER.log( + Level.WARNING, + "Failed to release remote lock (fail-closed): serverId={0}, lockId={1}, message={2}", + new Object[] {serverId, currentLockId, ex.getMessage()}); + } + } + + private void finishFailure(Host host, Throwable cause) { + if (!completionSignaled.compareAndSet(false, true)) { + return; + } + cancelPollTask(); + cancelHeartbeatTask(); + // Fail-closed: do not attempt release on communication/state failures. + // Remote side should reclaim via heartbeat timeout. + host.context().onFailure(cause); + } + + /** Called by the step's body callback when the lock body finishes - cancel heartbeat and release the lease. */ + public void onBodyFinished(Host host) { + completionSignaled.set(true); + cancelHeartbeatTask(); + releaseBestEffort(host); + } + + /** Aborts an in-flight session (step stopped): cancel timers, best-effort release, fail the context. */ + public void stop(Host host, Throwable cause) { + cancelPollTask(); + cancelHeartbeatTask(); + // Unified remote lock cleanup: release if held (no-op when nothing acquired yet). + releaseBestEffort(host); + completionSignaled.set(true); + host.context().onFailure(cause); + } + + /** Resumes the session after a controller restart (rebuilds the poll loop or fails closed). */ + @SuppressFBWarnings( + value = "REC_CATCH_EXCEPTION", + justification = "Resume is best-effort; any failure resuming the poll loop fails the step closed.") + public void onResume(Host host) { + if (lockId == null || lockId.isEmpty()) { + // Nothing was enqueued before the restart - nothing to resume. + return; + } + if (bodyStarted) { + // Body was executing when Jenkins restarted. The body is interrupted by Jenkins; we + // best-effort release the remote lock so the server doesn't hold it indefinitely. + LOGGER.log( + Level.WARNING, + "Jenkins restarted during remote lock body execution. " + + "Releasing remote lock best-effort. serverId={0}, lockId={1}", + new Object[] {serverId, lockId}); + releaseBestEffort(host); + try { + host.context() + .onFailure(new AbortException("Jenkins restarted during remote lock body execution " + + "(serverId=" + serverId + ", lockId=" + lockId + "). " + + "Remote lock released best-effort.")); + } catch (Exception ex) { + LOGGER.log(Level.WARNING, "Failed to signal remote body failure after restart", ex); + } + return; + } + // QUEUED or ACQUIRED state (polling not yet complete): resume polling + LockableResourcesManager lrm = LockableResourcesManager.get(); + try { + RemoteConnection remote = RemoteLockRouting.findConnection(lrm, serverId); + Run run = host.context().get(Run.class); + String authorizationHeader = RemoteCredentials.basicAuthHeader(remote, run); + RemoteApiClient client = new RemoteApiClient(); + String displayTarget = lockId; // best-effort description post-restart + // Restart is not a poll failure: start the post-restart retry budget fresh so a + // long pre-restart QUEUED period does not shrink it. + consecutivePollFailures = 0; + LOGGER.log( + Level.INFO, + "Resuming remote lock polling after restart: serverId={0}, lockId={1}", + new Object[] {serverId, lockId}); + startPolling(host, remote, authorizationHeader, client, run, displayTarget); + } catch (Exception ex) { + LOGGER.log(Level.WARNING, "Cannot resume remote polling after restart", ex); + try { + host.context().onFailure(ex); + } catch (Exception signalEx) { + LOGGER.log(Level.FINE, "Best-effort: could not signal resume failure to the step context", signalEx); + } + } + } + + private void cancelPollTask() { + ScheduledFuture task = pollTask; + if (task != null) { + task.cancel(false); + pollTask = null; + } + } + + private void cancelHeartbeatTask() { + ScheduledFuture task = heartbeatTask; + if (task != null) { + task.cancel(false); + heartbeatTask = null; + } + } + + private String buildFailureMessage(RemoteAcquireStatus status) { + String message = status.getMessage(); + String errorCode = status.getErrorCode(); + return "Remote acquire failed (serverId=" + + serverId + + ", lockId=" + + lockId + + ", state=" + + status.getState() + + ", errorCode=" + + errorCode + + ", message=" + + message + + ")"; + } +} diff --git a/src/main/java/org/jenkins/plugins/lockableresources/remote/RemoteLockState.java b/src/main/java/org/jenkins/plugins/lockableresources/remote/RemoteLockState.java new file mode 100644 index 000000000..3cb9fb2b1 --- /dev/null +++ b/src/main/java/org/jenkins/plugins/lockableresources/remote/RemoteLockState.java @@ -0,0 +1,23 @@ +/* + * The MIT License + * + * See the "LICENSE.txt" file for full copyright and license information. + */ +package org.jenkins.plugins.lockableresources.remote; + +/** + * Server-side state of a remote lock record. + * Distinct from the client-facing {@link RemoteAcquireState}. + */ +public enum RemoteLockState { + /** Waiting to acquire the resource (resource is currently busy). */ + QUEUED, + /** Resource is locked; client must send periodic heartbeats. */ + ACQUIRED, + /** Resource was already locked and skipIfLocked=true was requested. */ + SKIPPED, + /** Acquire failed (e.g. resource not found). */ + FAILED, + /** Heartbeat timed out; resource is held pending admin review. */ + STALE +} diff --git a/src/main/java/org/jenkins/plugins/lockableresources/remote/RemoteQueueEntry.java b/src/main/java/org/jenkins/plugins/lockableresources/remote/RemoteQueueEntry.java new file mode 100644 index 000000000..999da69c7 --- /dev/null +++ b/src/main/java/org/jenkins/plugins/lockableresources/remote/RemoteQueueEntry.java @@ -0,0 +1,135 @@ +/* + * The MIT License + * + * See the "LICENSE.txt" file for full copyright and license information. + */ +package org.jenkins.plugins.lockableresources.remote; + +import edu.umd.cs.findbugs.annotations.CheckForNull; +import edu.umd.cs.findbugs.annotations.NonNull; +import java.util.List; +import java.util.concurrent.TimeUnit; +import org.jenkins.plugins.lockableresources.LockableResource; +import org.jenkins.plugins.lockableresources.LockableResourcesManager; +import org.jenkins.plugins.lockableresources.queue.LockableResourcesStruct; +import org.kohsuke.accmod.Restricted; +import org.kohsuke.accmod.restrictions.NoExternalUse; + +/** + * Remote-lock counterpart to {@link org.jenkins.plugins.lockableresources.queue.QueuedContextStruct}. + * + *

Holds a pending remote acquire request in the LRM queue so that remote locks participate in the same + * priority-sorted queue as local pipeline {@code lock()} steps. The request's requirements are carried as + * the same {@link LockableResourcesStruct} list local lock() uses, so queue promotion resolves through the + * canonical {@code getAvailableResources} path. + * + *

All access must be under {@code LockableResourcesManager.syncResources}. + */ +@Restricted(NoExternalUse.class) +public final class RemoteQueueEntry { + + @NonNull + private final RemoteLockRecord record; + + /** Canonical requirements (main + extra), mirroring {@code LockStep.getResources()}. */ + @NonNull + private final List structs; + + private final int priority; + private final long timeoutDeadlineMillis; + + /** Resources resolved by the LRM queue scan, ready to be locked on promotion. */ + private transient List resolved; + + public RemoteQueueEntry( + @NonNull RemoteLockRecord record, + @NonNull List structs, + int priority, + long timeoutForAllocateResource, + @NonNull String timeoutUnit) { + this.record = record; + this.structs = structs; + this.priority = priority; + if (timeoutForAllocateResource > 0) { + long deadlineMs; + try { + TimeUnit tu = TimeUnit.valueOf(timeoutUnit.toUpperCase(java.util.Locale.ENGLISH)); + deadlineMs = System.currentTimeMillis() + tu.toMillis(timeoutForAllocateResource); + } catch (IllegalArgumentException e) { + deadlineMs = 0; + } + this.timeoutDeadlineMillis = deadlineMs; + } else { + this.timeoutDeadlineMillis = 0; + } + } + + @NonNull + public RemoteLockRecord getRecord() { + return record; + } + + @NonNull + public List getStructs() { + return structs; + } + + @NonNull + public String getLockId() { + return record.getLockId(); + } + + public int getPriority() { + return priority; + } + + public long getTimeoutDeadlineMillis() { + return timeoutDeadlineMillis; + } + + @CheckForNull + public List getResolved() { + return resolved; + } + + public void setResolved(@NonNull List resolved) { + this.resolved = resolved; + } + + public boolean isValid() { + return record.getState() == RemoteLockState.QUEUED; + } + + public boolean isTimedOut() { + return timeoutDeadlineMillis > 0 && System.currentTimeMillis() > timeoutDeadlineMillis; + } + + /** + * Called when {@code resources} have been allocated. Transitions the record to ACQUIRED and builds + * {@code lockEnvVars} (including resource-property env vars) via {@link RemoteResolver#remoteLockEnvVars} + * - the same helper the immediate-acquire path uses, so both paths produce identical env vars + * (shared with local {@code lock()} through {@code LockStepExecution.buildLockEnvVars}). + */ + public void onAcquired(@NonNull List resources) { + record.markAcquired( + LockableResourcesManager.getResourcesNames(resources), + RemoteResolver.remoteLockEnvVars(record.getLockRequest().getVariable(), resources)); + } + + /** Called when the queue timeout expires. Transitions the record to FAILED. */ + public void onTimeout() { + record.markFailed("LOCK_WAIT_TIMEOUT"); + } + + @NonNull + public RemoteLockRequest getLockRequest() { + return record.getLockRequest(); + } + + @Override + public String toString() { + return "RemoteQueueEntry{lockId=" + record.getLockId() + + ", priority=" + priority + + ", state=" + record.getState() + "}"; + } +} diff --git a/src/main/java/org/jenkins/plugins/lockableresources/remote/RemoteResolver.java b/src/main/java/org/jenkins/plugins/lockableresources/remote/RemoteResolver.java new file mode 100644 index 000000000..ffbc0b3cc --- /dev/null +++ b/src/main/java/org/jenkins/plugins/lockableresources/remote/RemoteResolver.java @@ -0,0 +1,194 @@ +/* + * The MIT License + * + * See the "LICENSE.txt" file for full copyright and license information. + */ +package org.jenkins.plugins.lockableresources.remote; + +import edu.umd.cs.findbugs.annotations.CheckForNull; +import edu.umd.cs.findbugs.annotations.NonNull; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import org.jenkins.plugins.lockableresources.LockStepExecution; +import org.jenkins.plugins.lockableresources.LockableResource; +import org.jenkins.plugins.lockableresources.LockableResourceProperty; +import org.jenkins.plugins.lockableresources.LockableResourcesManager; +import org.jenkins.plugins.lockableresources.ResourceSelectStrategy; +import org.jenkins.plugins.lockableresources.queue.LockableResourcesStruct; +import org.kohsuke.accmod.Restricted; +import org.kohsuke.accmod.restrictions.NoExternalUse; + +/** + * Server-side resolution for remote lock requests: the admission check (existence/exposure) and + * resolution through the canonical {@link LockableResourcesManager#getAvailableResources} path - + * the same one local {@code lock()} uses, with the configured {@code exposeLabel} set as the candidate + * filter. The server never re-implements {@code lock()} semantics; this class only decides + * which resources a remote request may see and resolves them, then hands off to the manager. + * + *

A pure, stateless collaborator over the manager's public API. Every call must be made while holding + * {@link LockableResourcesManager#syncResources} - the caller acquires it; this class never takes the lock + * itself. + */ +@Restricted(NoExternalUse.class) +public final class RemoteResolver { + + private final LockableResourcesManager lrm; + + public RemoteResolver(@NonNull LockableResourcesManager lrm) { + this.lrm = lrm; + } + + /** + * Admission check for a remote request: every selector (the main one and each {@code extra} + * entry) must reference something this remote client could actually lock - a resource that exists and + * is exposed, or a label with at least one exposed candidate. Exposure means "carries any configured + * {@code exposeLabel}" (see {@link LockableResourcesManager#getExposeLabels()}). This is the only + * remote-specific gate; the actual resolution still goes through the canonical + * {@link LockableResourcesManager#getAvailableResources} path. Must be called under + * {@link LockableResourcesManager#syncResources}. + * + * @return an error code ({@code "UNKNOWN_RESOURCE"} / {@code "UNKNOWN_LABEL"}) or {@code null} when all + * selectors are admissible. The caller maps the code to HTTP 404. + */ + @CheckForNull + public String validateRemoteSelectors(@NonNull RemoteLockRequest req) { + Set exposeLabels = lrm.getExposeLabels(); + String main = validateSelector(req.getResource(), req.getLabel(), exposeLabels); + if (main != null) { + return main; + } + List extra = req.getExtra(); + if (extra != null) { + for (RemoteLockRequest.ExtraResource e : extra) { + String err = validateSelector(e.getResource(), e.getLabel(), exposeLabels); + if (err != null) { + return err; + } + } + } + return null; + } + + @CheckForNull + private String validateSelector( + @CheckForNull String resource, @CheckForNull String label, @NonNull Set exposeLabels) { + if (resource != null) { + LockableResource r = lrm.fromName(resource); + return (r != null && isExposed(r, exposeLabels)) ? null : "UNKNOWN_RESOURCE"; + } + if (label != null && !label.isEmpty()) { + return hasExposedCandidate(label, exposeLabels) ? null : "UNKNOWN_LABEL"; + } + return null; // absent selector (e.g. main when extra-only) - nothing to validate + } + + /** A resource is exposed to remote clients iff it carries at least one configured exposeLabel (OR). */ + private static boolean isExposed(@NonNull LockableResource r, @NonNull Set exposeLabels) { + return !exposeLabels.isEmpty() && !Collections.disjoint(r.getLabelsAsList(), exposeLabels); + } + + private boolean hasExposedCandidate(@NonNull String label, @NonNull Set exposeLabels) { + if (exposeLabels.isEmpty()) { + return false; // exposeLabel is opt-in: empty exposes nothing + } + for (LockableResource r : lrm.getResources()) { + List labels = r.getLabelsAsList(); + if (labels.contains(label) && !Collections.disjoint(labels, exposeLabels)) { + return true; + } + } + return false; + } + + /** + * Builds the canonical {@link LockableResourcesStruct} list for a remote request, mirroring + * {@code LockStep.getResources()} (main selector + each {@code extra} entry). Resources are resolved by + * name only (no ephemeral creation): admission ({@link #validateRemoteSelectors}) has already confirmed + * every named selector exists and is exposed, so a remote request never materialises new resources on + * the server. Must be called under {@link LockableResourcesManager#syncResources}. + */ + @NonNull + public List toRemoteStructs(@NonNull RemoteLockRequest req) { + List structs = new ArrayList<>(); + addRemoteStruct(structs, req.getResource(), req.getLabel(), req.getQuantity()); + List extra = req.getExtra(); + if (extra != null) { + for (RemoteLockRequest.ExtraResource e : extra) { + addRemoteStruct(structs, e.getResource(), e.getLabel(), e.getQuantity()); + } + } + return structs; + } + + private void addRemoteStruct( + List structs, + @CheckForNull String resource, + @CheckForNull String label, + int quantity) { + boolean hasLabel = label != null && !label.isEmpty(); + if (resource == null && !hasLabel) { + return; // absent selector (e.g. main when extra-only) + } + List names = new ArrayList<>(); + if (resource != null) { + // No ephemeral creation here: admission (validateRemoteSelectors) guarantees the name exists and + // is exposed, so a remote request never creates new resources on the server. + names.add(resource); + } + structs.add(new LockableResourcesStruct(names, label, quantity)); + } + + /** + * Resolves a remote request (given its canonical {@code structs}, from {@link #toRemoteStructs}) + * through the canonical {@link LockableResourcesManager#getAvailableResources} path, applying the + * configured {@code exposeLabel} set as the candidate-visibility filter (a resource is visible iff it + * carries any exposeLabel - OR). The filter is a plain {@link java.util.function.Predicate} so the + * canonical resolver never learns about exposeLabel; the "requested-label AND exposeLabel" intersection + * is expressed entirely here, leaving local lock()'s single-label matching untouched. Returns the + * resources to lock (atomically) or {@code null} if not all currently satisfiable (-> QUEUED, like + * local). Shared by the immediate acquire and queue-promotion paths. Must be called under + * {@link LockableResourcesManager#syncResources}. + */ + @CheckForNull + public List availableForRemote( + @NonNull List structs, @NonNull RemoteLockRequest req) { + if (structs.isEmpty()) { + return null; + } + Set exposeLabels = lrm.getExposeLabels(); + List available = lrm.getAvailableResources( + structs, null, parseSelectStrategy(req.getResourceSelectStrategy()), r -> isExposed(r, exposeLabels)); + return (available == null || available.isEmpty()) ? null : available; + } + + /** + * Builds {@code lockEnvVars} for an acquired remote lock from the resolved resources, via the shared + * {@link LockStepExecution#buildLockEnvVars} - identical to local, including resource-property env + * vars. Returns {@code null} when no {@code variable} was requested. + */ + @CheckForNull + public static Map remoteLockEnvVars( + @CheckForNull String variable, @NonNull List resources) { + LinkedHashMap> map = new LinkedHashMap<>(); + for (LockableResource r : resources) { + map.put(r.getName(), r.getProperties()); + } + return LockStepExecution.buildLockEnvVars(variable, map); + } + + private static ResourceSelectStrategy parseSelectStrategy(@CheckForNull String strategy) { + if (strategy != null) { + try { + return ResourceSelectStrategy.valueOf(strategy.toUpperCase(Locale.ENGLISH)); + } catch (IllegalArgumentException ignored) { + // fall through to default + } + } + return ResourceSelectStrategy.SEQUENTIAL; + } +} diff --git a/src/main/java/org/jenkins/plugins/lockableresources/util/Constants.java b/src/main/java/org/jenkins/plugins/lockableresources/util/Constants.java new file mode 100644 index 000000000..72ecef5cc --- /dev/null +++ b/src/main/java/org/jenkins/plugins/lockableresources/util/Constants.java @@ -0,0 +1,29 @@ +package org.jenkins.plugins.lockableresources.util; + +public class Constants { + /// Enable mirror nodes to lockable-resources + public static final String SYSTEM_PROPERTY_ENABLE_NODE_MIRROR = + "org.jenkins.plugins.lockableresources.ENABLE_NODE_MIRROR"; + /// Disable saving lockable resources states, properties ... into local file system. + /// This option makes the plugin much faster (everything is in cache) but + /// **Keep in mind, that you will lost all your manual changed properties** + /// The best way is to use it with JCaC plugin. + public static final String SYSTEM_PROPERTY_DISABLE_SAVE = "org.jenkins.plugins.lockableresources.DISABLE_SAVE"; + /// Enable to print lock causes. Keep in mind, that the log output may grove depends on count of + /// blocked resources. + public static final String SYSTEM_PROPERTY_PRINT_BLOCKED_RESOURCE = + "org.jenkins.plugins.lockableresources.PRINT_BLOCKED_RESOURCE"; + public static final String SYSTEM_PROPERTY_PRINT_QUEUE_INFO = + "org.jenkins.plugins.lockableresources.PRINT_QUEUE_INFO"; + /// Enable asynchronous save to reduce syncResources lock hold time. + public static final String SYSTEM_PROPERTY_ASYNC_SAVE = "org.jenkins.plugins.lockableresources.ASYNC_SAVE"; + /// Coalesce window (ms) for async saves — rapid state changes within this window are batched. + public static final String SYSTEM_PROPERTY_SAVE_COALESCE_MS = + "org.jenkins.plugins.lockableresources.SAVE_COALESCE_MS"; + /// TTL (ms) for Groovy script evaluation result cache per resource. + public static final String SYSTEM_PROPERTY_SCRIPT_CACHE_TTL_MS = + "org.jenkins.plugins.lockableresources.SCRIPT_CACHE_TTL_MS"; + /// TTL (ms) for label expression evaluation result cache per resource. + public static final String SYSTEM_PROPERTY_LABEL_CACHE_TTL_MS = + "org.jenkins.plugins.lockableresources.LABEL_CACHE_TTL_MS"; +} diff --git a/src/main/java/org/jenkins/plugins/lockableresources/util/SerializableSecureGroovyScript.java b/src/main/java/org/jenkins/plugins/lockableresources/util/SerializableSecureGroovyScript.java index e1ad77345..3841a88ac 100644 --- a/src/main/java/org/jenkins/plugins/lockableresources/util/SerializableSecureGroovyScript.java +++ b/src/main/java/org/jenkins/plugins/lockableresources/util/SerializableSecureGroovyScript.java @@ -1,39 +1,21 @@ /* * The MIT License * - * Copyright (c) 2017 Oleg Nenashev. - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in - * all copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN - * THE SOFTWARE. + * See the "LICENSE.txt" file for full copyright and license information. */ package org.jenkins.plugins.lockableresources.util; +import edu.umd.cs.findbugs.annotations.CheckForNull; +import edu.umd.cs.findbugs.annotations.NonNull; +import edu.umd.cs.findbugs.annotations.Nullable; +import hudson.model.Descriptor; +import hudson.util.FormValidation; import java.io.Serializable; import java.net.MalformedURLException; import java.util.ArrayList; import java.util.List; import java.util.logging.Level; import java.util.logging.Logger; -import javax.annotation.CheckForNull; -import javax.annotation.Nonnull; -import javax.annotation.Nullable; - -import hudson.util.FormValidation; import org.jenkinsci.plugins.scriptsecurity.sandbox.groovy.SecureGroovyScript; import org.jenkinsci.plugins.scriptsecurity.scripts.ClasspathEntry; import org.jenkinsci.plugins.scriptsecurity.scripts.ScriptApproval; @@ -42,83 +24,87 @@ /** * Wrapper for a {@link SecureGroovyScript}. + * * @author Oleg Nenashev */ @Restricted(NoExternalUse.class) public class SerializableSecureGroovyScript implements Serializable { - private static final long serialVersionUID = 1L; - - @CheckForNull - private final String script; - private final boolean sandbox; - /** - * {@code null} if and only if the {@link #script is null}. - */ - @Nullable - private final ArrayList classPathEntries; - - private static final Logger LOGGER = Logger.getLogger(SerializableSecureGroovyScript.class.getName()); - - public SerializableSecureGroovyScript(@CheckForNull SecureGroovyScript secureScript) { - if (secureScript == null) { - script = null; - sandbox = false; - classPathEntries = null; - } else { - this.script = secureScript.getScript(); - this.sandbox = secureScript.isSandbox(); - - List classpath = secureScript.getClasspath(); - classPathEntries = new ArrayList<>(classpath.size()); - for (ClasspathEntry e : classpath) { - classPathEntries.add(new SerializableClassPathEntry(e)); - } - } - } - - @CheckForNull - public SecureGroovyScript rehydrate() { - if (script == null) { - return null; - } - - ArrayList p = new ArrayList<>(classPathEntries.size()); - for (SerializableClassPathEntry e : classPathEntries) { - ClasspathEntry entry = e.rehydrate(); - if (entry != null) { - p.add(entry); - } - } - - return new SecureGroovyScript(script, sandbox, p); - } - - private static class SerializableClassPathEntry implements Serializable { - - private static final long serialVersionUID = 1L; - - private final String url; - - private SerializableClassPathEntry(@Nonnull ClasspathEntry entry) { - this.url = entry.getPath(); - } - - @CheckForNull - private ClasspathEntry rehydrate(){ - try { - ClasspathEntry entry = new ClasspathEntry(url); - if (ScriptApproval.get().checking(entry).kind.equals(FormValidation.Kind.OK)) { - return entry; - } else { - return null; - } - } catch (MalformedURLException ex) { - // Unrealistic - LOGGER.log(Level.SEVERE, "Failed to rehydrate the URL " + url + ". It will be skipped", ex); - return null; - } - } - - } + private static final long serialVersionUID = 1L; + + @CheckForNull + private final String script; + + private final boolean sandbox; + /** {@code null} if and only if the {@link #script is null}. */ + @Nullable + private final ArrayList classPathEntries; + + private static final Logger LOGGER = Logger.getLogger(SerializableSecureGroovyScript.class.getName()); + + public SerializableSecureGroovyScript(@CheckForNull SecureGroovyScript secureScript) { + if (secureScript == null) { + script = null; + sandbox = false; + classPathEntries = null; + } else { + this.script = secureScript.getScript(); + this.sandbox = secureScript.isSandbox(); + + List classpath = secureScript.getClasspath(); + classPathEntries = new ArrayList<>(classpath.size()); + for (ClasspathEntry e : classpath) { + classPathEntries.add(new SerializableClassPathEntry(e)); + } + } + } + + @CheckForNull + public String getScript() { + return script; + } + + @CheckForNull + public SecureGroovyScript rehydrate() throws Descriptor.FormException { + if (script == null) { + return null; + } + + ArrayList p = new ArrayList<>(classPathEntries.size()); + for (SerializableClassPathEntry e : classPathEntries) { + ClasspathEntry entry = e.rehydrate(); + if (entry != null) { + p.add(entry); + } + } + + return new SecureGroovyScript(script, sandbox, p); + } + + private static class SerializableClassPathEntry implements Serializable { + + private static final long serialVersionUID = 1L; + + private final String url; + + private SerializableClassPathEntry(@NonNull ClasspathEntry entry) { + this.url = entry.getPath(); + } + + @CheckForNull + private ClasspathEntry rehydrate() { + try { + ClasspathEntry entry = new ClasspathEntry(url); + if (ScriptApproval.get().checking(entry).kind.equals(FormValidation.Kind.OK)) { + return entry; + } else { + return null; + } + } catch (MalformedURLException ex) { + // Unrealistic + LOGGER.log(Level.SEVERE, "Failed to rehydrate the URL " + url + ". It will be skipped", ex); + return null; + } + } + } } diff --git a/src/main/resources/org/jenkins/plugins/lockableresources/LockStep/config.jelly b/src/main/resources/org/jenkins/plugins/lockableresources/LockStep/config.jelly index 90affe3e7..835dfba36 100644 --- a/src/main/resources/org/jenkins/plugins/lockableresources/LockStep/config.jelly +++ b/src/main/resources/org/jenkins/plugins/lockableresources/LockStep/config.jelly @@ -1,29 +1,47 @@ - - - - - - - - - - - - - - - - - - - - -
-
-
-
-
+ xmlns:l="/lib/layout" xmlns:t="/lib/hudson" xmlns:f="/lib/form"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+
diff --git a/src/main/resources/org/jenkins/plugins/lockableresources/LockStep/config.properties b/src/main/resources/org/jenkins/plugins/lockableresources/LockStep/config.properties new file mode 100644 index 000000000..a912b9524 --- /dev/null +++ b/src/main/resources/org/jenkins/plugins/lockableresources/LockStep/config.properties @@ -0,0 +1,35 @@ +# The MIT License +# +# Copyright 2022 Martin Pokorny. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +entry.resource.title=Resource +entry.label.title=Label +entry.quantity.title=Quantity +entry.variable.title=Result variable +entry.reason.title=Reason +entry.inversePrecedence.checkbox.title=Inverse precedence +entry.skipIfLocked.title=Skip if locked +entry.priority.title=Queue priority +entry.resourceSelectStrategy.title=Strategy for resource selection +entry.timeoutForAllocateResource.title=Timeout for resource allocation +entry.timeoutUnit.title=Timeout unit +entry.extra.title=Extra resources +entry.extra.add=Add Resource diff --git a/src/main/resources/org/jenkins/plugins/lockableresources/LockStep/config_cs.properties b/src/main/resources/org/jenkins/plugins/lockableresources/LockStep/config_cs.properties new file mode 100644 index 000000000..e8c783f38 --- /dev/null +++ b/src/main/resources/org/jenkins/plugins/lockableresources/LockStep/config_cs.properties @@ -0,0 +1,31 @@ +# The MIT License +# +# Copyright 2022 Martin Pokorny. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +entry.resource.title=Zdroj +entry.label.title=Popisek +entry.quantity.title=Mno\u017estv\u00ed +entry.variable.title=Prom\u011bnn\u00e1 v\u00fdsledk\u016f +entry.inversePrecedence.checkbox.title=Obr\u00e1cen\u00e9 po\u0159ad\u00ed +entry.skipIfLocked.title=P\u0159esko\u010dit frontu +entry.resourceSelectStrategy.title=Strategie v\u00fdb\u011bru zdroj\u016f +entry.extra.title=Dodate\u010dn\u00e9 zdroje +entry.extra.add=P\u0159idat zdroj diff --git a/src/main/resources/org/jenkins/plugins/lockableresources/LockStep/config_de.properties b/src/main/resources/org/jenkins/plugins/lockableresources/LockStep/config_de.properties new file mode 100644 index 000000000..6294b40c8 --- /dev/null +++ b/src/main/resources/org/jenkins/plugins/lockableresources/LockStep/config_de.properties @@ -0,0 +1,31 @@ +# The MIT License +# +# Copyright 2022 Martin Pokorny. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +entry.resource.title=Ressource +entry.label.title=Label +entry.quantity.title=Anzahl +entry.variable.title=Ergebnisvariable +entry.inversePrecedence.checkbox.title=Umgekehrter Vorrang +entry.skipIfLocked.title=Warteschlange \u00fcberspringen +entry.resourceSelectStrategy.title=Strategie f\u00fcr Ressourcenauswahl +entry.extra.title=Zus\u00e4tzliche Ressourcen +entry.extra.add=Ressource hinzuf\u00fcgen diff --git a/src/main/resources/org/jenkins/plugins/lockableresources/LockStep/config_fr.properties b/src/main/resources/org/jenkins/plugins/lockableresources/LockStep/config_fr.properties new file mode 100644 index 000000000..2268459e1 --- /dev/null +++ b/src/main/resources/org/jenkins/plugins/lockableresources/LockStep/config_fr.properties @@ -0,0 +1,30 @@ +# The MIT License +# +# Copyright 2022 Martin Pokorny. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +entry.resource.title=Ressource +entry.label.title=Libell\u00e9 +entry.quantity.title=Quantit\u00e9 +entry.variable.title=Variable r\u00e9sultat +entry.inversePrecedence.checkbox.title=Priorit\u00e9 invers\u00e9e +entry.skipIfLocked.title=Sauter la file d'attente +entry.extra.title=Ressources suppl\u00e9mentaires +entry.extra.add=Ajouter une ressource \ No newline at end of file diff --git a/src/main/resources/org/jenkins/plugins/lockableresources/LockStep/config_sk.properties b/src/main/resources/org/jenkins/plugins/lockableresources/LockStep/config_sk.properties new file mode 100644 index 000000000..3ba850a01 --- /dev/null +++ b/src/main/resources/org/jenkins/plugins/lockableresources/LockStep/config_sk.properties @@ -0,0 +1,31 @@ +# The MIT License +# +# Copyright 2022 Martin Pokorny. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +entry.resource.title=Zdroj +entry.label.title=\u0160t\u00edtok +entry.quantity.title=Po\u010det +entry.variable.title=Pramenn\u00e1 s v\u00fdsledkami +entry.inversePrecedence.checkbox.title=Opa\u010dn\u00e9 poradie +entry.skipIfLocked.title=Predbehn\u00fa\u0165 rad +entry.resourceSelectStrategy.title=Strat\u00e9gia v\u00fdberu zdrojov +entry.extra.title=Dodato\u010dn\u00e9 zdroje +entry.extra.add=Prida\u0165 zdroj diff --git a/src/main/resources/org/jenkins/plugins/lockableresources/LockStep/help-inversePrecedence.html b/src/main/resources/org/jenkins/plugins/lockableresources/LockStep/help-inversePrecedence.html index a182da914..1a14e6ee6 100644 --- a/src/main/resources/org/jenkins/plugins/lockableresources/LockStep/help-inversePrecedence.html +++ b/src/main/resources/org/jenkins/plugins/lockableresources/LockStep/help-inversePrecedence.html @@ -1,8 +1,13 @@

-

- By default waiting builds get the lock in the same order they requested to acquire it. -

-

- By checking this option the newest build in the waiting queue will get the lock first. -

+

+ By default waiting builds get the lock in the same order they requested to acquire it. +

+

+ By checking this option the newest build in the waiting queue will get the lock first. +

+

+ See also + examples + . +

diff --git a/src/main/resources/org/jenkins/plugins/lockableresources/LockStep/help-label.html b/src/main/resources/org/jenkins/plugins/lockableresources/LockStep/help-label.html index e02149d02..d89e6a47c 100644 --- a/src/main/resources/org/jenkins/plugins/lockableresources/LockStep/help-label.html +++ b/src/main/resources/org/jenkins/plugins/lockableresources/LockStep/help-label.html @@ -1,6 +1,14 @@
-

- The label of the resources to be locked as defined in Global settings. - Either a resource or a label need to be specified. -

+

+ The label of the resources to be locked as defined in Global settings. +

+

+ Either a resource or a label need to be specified. +

+

+ See also + examples and + Scripted vs declarative pipeline + . +

diff --git a/src/main/resources/org/jenkins/plugins/lockableresources/LockStep/help-priority.html b/src/main/resources/org/jenkins/plugins/lockableresources/LockStep/help-priority.html new file mode 100644 index 000000000..9289be778 --- /dev/null +++ b/src/main/resources/org/jenkins/plugins/lockableresources/LockStep/help-priority.html @@ -0,0 +1,14 @@ +
+

+ The priority of the lock, +

+

+ which takes an integer number that defines the order in which concurrent jobs waiting for the same resource are served. + The job with the highest number would get the resource first. If the priority is equal, the current precedence (first comes first) would be applied. +

+

+ See also + examples + . +

+
diff --git a/src/main/resources/org/jenkins/plugins/lockableresources/LockStep/help-quantity.html b/src/main/resources/org/jenkins/plugins/lockableresources/LockStep/help-quantity.html index 7646eb821..95f5f3bfe 100644 --- a/src/main/resources/org/jenkins/plugins/lockableresources/LockStep/help-quantity.html +++ b/src/main/resources/org/jenkins/plugins/lockableresources/LockStep/help-quantity.html @@ -1,6 +1,9 @@
-

- The quantity of resources with the specified label to be locked as defined in Global settings. - Either a resource or a label need to be specified. -

+

+ The quantity of resources with the specified label to be locked as defined in Global settings. +

+

+ Either a resource or a label need to be specified. + Empty value or 0 means lock all matching resources. +

diff --git a/src/main/resources/org/jenkins/plugins/lockableresources/LockStep/help-reason.html b/src/main/resources/org/jenkins/plugins/lockableresources/LockStep/help-reason.html new file mode 100644 index 000000000..b1fee9c81 --- /dev/null +++ b/src/main/resources/org/jenkins/plugins/lockableresources/LockStep/help-reason.html @@ -0,0 +1,13 @@ +
+

+ An optional reason explaining why the resource is being locked. + This is displayed in the lockable resources UI while the resource is locked, + helping other users understand why a resource is currently unavailable. +

+

+ Example: +

lock(resource: 'my-resource', reason: 'Running integration tests') {
+    // ...
+}
+

+
diff --git a/src/main/resources/org/jenkins/plugins/lockableresources/LockStep/help-resource.html b/src/main/resources/org/jenkins/plugins/lockableresources/LockStep/help-resource.html index 6c5ec5177..fc9e44264 100644 --- a/src/main/resources/org/jenkins/plugins/lockableresources/LockStep/help-resource.html +++ b/src/main/resources/org/jenkins/plugins/lockableresources/LockStep/help-resource.html @@ -1,7 +1,9 @@
-

- The resource name to lock as defined in Global settings. - If the resource does not exist in Global Settings it will be automatically created on build execution. - Either a resource or a label need to be specified. -

+

+ The resource name to lock as defined in Global settings. +

+

+ If the resource does not exist in Global Settings it will be automatically created on build execution. + Either a resource or a label need to be specified. +

diff --git a/src/main/resources/org/jenkins/plugins/lockableresources/LockStep/help-resourceSelectStrategy.html b/src/main/resources/org/jenkins/plugins/lockableresources/LockStep/help-resourceSelectStrategy.html new file mode 100644 index 000000000..c1b9a4098 --- /dev/null +++ b/src/main/resources/org/jenkins/plugins/lockableresources/LockStep/help-resourceSelectStrategy.html @@ -0,0 +1,7 @@ +
+

+ The strategy used to chose which available resources get locked. + By default, the strategy will be "sequential" and resources are locked following the order in the lockable resources list. + Set the strategy to "random" to randomize the order in which resources are locked. +

+
diff --git a/src/main/resources/org/jenkins/plugins/lockableresources/LockStep/help-skipIfLocked.html b/src/main/resources/org/jenkins/plugins/lockableresources/LockStep/help-skipIfLocked.html new file mode 100644 index 000000000..cff1c30d0 --- /dev/null +++ b/src/main/resources/org/jenkins/plugins/lockableresources/LockStep/help-skipIfLocked.html @@ -0,0 +1,14 @@ +
+

+ By default waiting builds get the lock. +

+

+ By checking this option the body will not be executed if there is a queue. + It will only take the lock if it can be taken immediately. +

+

+ See also + examples + . +

+
diff --git a/src/main/resources/org/jenkins/plugins/lockableresources/LockStep/help-timeoutForAllocateResource.html b/src/main/resources/org/jenkins/plugins/lockableresources/LockStep/help-timeoutForAllocateResource.html new file mode 100644 index 000000000..e4b2b5bd1 --- /dev/null +++ b/src/main/resources/org/jenkins/plugins/lockableresources/LockStep/help-timeoutForAllocateResource.html @@ -0,0 +1,9 @@ +
+

+ Maximum time (in the specified time unit) to wait for a resource to become available. +

+

+ If the resource is not acquired within this time, the pipeline step will fail with a timeout error. + Set to 0 (the default) to wait indefinitely. +

+
diff --git a/src/main/resources/org/jenkins/plugins/lockableresources/LockStep/help-timeoutUnit.html b/src/main/resources/org/jenkins/plugins/lockableresources/LockStep/help-timeoutUnit.html new file mode 100644 index 000000000..1554ba944 --- /dev/null +++ b/src/main/resources/org/jenkins/plugins/lockableresources/LockStep/help-timeoutUnit.html @@ -0,0 +1,8 @@ +
+

+ The time unit for the resource allocation timeout. +

+

+ Allowed values: SECONDS, MINUTES, HOURS. Default is MINUTES. +

+
diff --git a/src/main/resources/org/jenkins/plugins/lockableresources/LockStep/help-variable.html b/src/main/resources/org/jenkins/plugins/lockableresources/LockStep/help-variable.html index 6331541bc..9ff45eae6 100644 --- a/src/main/resources/org/jenkins/plugins/lockableresources/LockStep/help-variable.html +++ b/src/main/resources/org/jenkins/plugins/lockableresources/LockStep/help-variable.html @@ -1,13 +1,11 @@
-

- Name of an environment variable that will receive the comma separated list of the names of the locked resources while the block executes. -

-

- e.g.: -

-lock(abel: 'label', variable: 'var') {
-    echo "Resource locked: ${env.var}"
-}
-		
-

+

+ Name of an environment variable that will receive the comma separated list of the names of the locked resources while the block executes. +

+

+ See also + examples and + Scripted vs declarative pipeline + . +

diff --git a/src/main/resources/org/jenkins/plugins/lockableresources/LockStepResource/config.jelly b/src/main/resources/org/jenkins/plugins/lockableresources/LockStepResource/config.jelly index 9a30bd2be..27811e565 100644 --- a/src/main/resources/org/jenkins/plugins/lockableresources/LockStepResource/config.jelly +++ b/src/main/resources/org/jenkins/plugins/lockableresources/LockStepResource/config.jelly @@ -1,13 +1,16 @@ - - - - - - - - - + xmlns:l="/lib/layout" xmlns:t="/lib/hudson" xmlns:f="/lib/form"> + + + + + + + + + + + + diff --git a/src/main/resources/org/jenkins/plugins/lockableresources/LockStepResource/config.properties b/src/main/resources/org/jenkins/plugins/lockableresources/LockStepResource/config.properties new file mode 100644 index 000000000..7488d4d8f --- /dev/null +++ b/src/main/resources/org/jenkins/plugins/lockableresources/LockStepResource/config.properties @@ -0,0 +1,26 @@ +# The MIT License +# +# Copyright 2022 Martin Pokorny. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +entry.resource.title=Resource +entry.label.title=Label +entry.quantity.title=Quantity +entry.reason.title=Reason \ No newline at end of file diff --git a/src/main/resources/org/jenkins/plugins/lockableresources/LockStepResource/config_cs.properties b/src/main/resources/org/jenkins/plugins/lockableresources/LockStepResource/config_cs.properties new file mode 100644 index 000000000..7b4d77160 --- /dev/null +++ b/src/main/resources/org/jenkins/plugins/lockableresources/LockStepResource/config_cs.properties @@ -0,0 +1,25 @@ +# The MIT License +# +# Copyright 2022 Martin Pokorny. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +entry.resource.title=Zdroj +entry.label.title=Popisek +entry.quantity.title=Po\u010det \ No newline at end of file diff --git a/src/main/resources/org/jenkins/plugins/lockableresources/LockStepResource/config_de.properties b/src/main/resources/org/jenkins/plugins/lockableresources/LockStepResource/config_de.properties new file mode 100644 index 000000000..e51d7c592 --- /dev/null +++ b/src/main/resources/org/jenkins/plugins/lockableresources/LockStepResource/config_de.properties @@ -0,0 +1,25 @@ +# The MIT License +# +# Copyright 2022 Martin Pokorny. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +entry.resource.title=Ressource +entry.label.title=Label +entry.quantity.title=Menge \ No newline at end of file diff --git a/src/main/resources/org/jenkins/plugins/lockableresources/LockStepResource/config_fr.properties b/src/main/resources/org/jenkins/plugins/lockableresources/LockStepResource/config_fr.properties new file mode 100644 index 000000000..ba06595b9 --- /dev/null +++ b/src/main/resources/org/jenkins/plugins/lockableresources/LockStepResource/config_fr.properties @@ -0,0 +1,25 @@ +# The MIT License +# +# Copyright 2022 Martin Pokorny. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +entry.resource.title=Ressource +entry.label.title=Libell\u00e9 +entry.quantity.title=Quantit\u00e9 \ No newline at end of file diff --git a/src/main/resources/org/jenkins/plugins/lockableresources/LockStepResource/config_sk.properties b/src/main/resources/org/jenkins/plugins/lockableresources/LockStepResource/config_sk.properties new file mode 100644 index 000000000..5d2ef943d --- /dev/null +++ b/src/main/resources/org/jenkins/plugins/lockableresources/LockStepResource/config_sk.properties @@ -0,0 +1,25 @@ +# The MIT License +# +# Copyright 2022 Martin Pokorny. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +entry.resource.title=Zdroj +entry.label.title=\u0160t\u00edtok +entry.quantity.title=Po\u010det \ No newline at end of file diff --git a/src/main/resources/org/jenkins/plugins/lockableresources/LockStepResource/help-label.html b/src/main/resources/org/jenkins/plugins/lockableresources/LockStepResource/help-label.html index e02149d02..d89e6a47c 100644 --- a/src/main/resources/org/jenkins/plugins/lockableresources/LockStepResource/help-label.html +++ b/src/main/resources/org/jenkins/plugins/lockableresources/LockStepResource/help-label.html @@ -1,6 +1,14 @@
-

- The label of the resources to be locked as defined in Global settings. - Either a resource or a label need to be specified. -

+

+ The label of the resources to be locked as defined in Global settings. +

+

+ Either a resource or a label need to be specified. +

+

+ See also + examples and + Scripted vs declarative pipeline + . +

diff --git a/src/main/resources/org/jenkins/plugins/lockableresources/LockStepResource/help-quantity.html b/src/main/resources/org/jenkins/plugins/lockableresources/LockStepResource/help-quantity.html index 7646eb821..95f5f3bfe 100644 --- a/src/main/resources/org/jenkins/plugins/lockableresources/LockStepResource/help-quantity.html +++ b/src/main/resources/org/jenkins/plugins/lockableresources/LockStepResource/help-quantity.html @@ -1,6 +1,9 @@
-

- The quantity of resources with the specified label to be locked as defined in Global settings. - Either a resource or a label need to be specified. -

+

+ The quantity of resources with the specified label to be locked as defined in Global settings. +

+

+ Either a resource or a label need to be specified. + Empty value or 0 means lock all matching resources. +

diff --git a/src/main/resources/org/jenkins/plugins/lockableresources/LockStepResource/help-reason.html b/src/main/resources/org/jenkins/plugins/lockableresources/LockStepResource/help-reason.html new file mode 100644 index 000000000..4ade12d16 --- /dev/null +++ b/src/main/resources/org/jenkins/plugins/lockableresources/LockStepResource/help-reason.html @@ -0,0 +1,6 @@ +
+

+ An optional reason explaining why the resource is being locked. + This is displayed in the lockable resources UI while the resource is locked. +

+
diff --git a/src/main/resources/org/jenkins/plugins/lockableresources/LockStepResource/help-resource.html b/src/main/resources/org/jenkins/plugins/lockableresources/LockStepResource/help-resource.html index 6c5ec5177..fc9e44264 100644 --- a/src/main/resources/org/jenkins/plugins/lockableresources/LockStepResource/help-resource.html +++ b/src/main/resources/org/jenkins/plugins/lockableresources/LockStepResource/help-resource.html @@ -1,7 +1,9 @@
-

- The resource name to lock as defined in Global settings. - If the resource does not exist in Global Settings it will be automatically created on build execution. - Either a resource or a label need to be specified. -

+

+ The resource name to lock as defined in Global settings. +

+

+ If the resource does not exist in Global Settings it will be automatically created on build execution. + Either a resource or a label need to be specified. +

diff --git a/src/main/resources/org/jenkins/plugins/lockableresources/LockableResource/config.jelly b/src/main/resources/org/jenkins/plugins/lockableresources/LockableResource/config.jelly index 53eec81b1..41262b5d0 100644 --- a/src/main/resources/org/jenkins/plugins/lockableresources/LockableResource/config.jelly +++ b/src/main/resources/org/jenkins/plugins/lockableresources/LockableResource/config.jelly @@ -10,17 +10,23 @@ * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * --> - - - - - - - - - - - - + xmlns:l="/lib/layout" xmlns:t="/lib/hudson" xmlns:f="/lib/form"> + + + + + + + + + + + + +
+ +
+
+
+
diff --git a/src/main/resources/org/jenkins/plugins/lockableresources/LockableResource/config.properties b/src/main/resources/org/jenkins/plugins/lockableresources/LockableResource/config.properties new file mode 100644 index 000000000..bd6fe2fc4 --- /dev/null +++ b/src/main/resources/org/jenkins/plugins/lockableresources/LockableResource/config.properties @@ -0,0 +1,28 @@ +# The MIT License +# +# Copyright 2022 Martin Pokorny. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +entry.name.title=Name +entry.description.title=Description +entry.labels.title=Labels +entry.properties.title=Properties +entry.properties.add=Add Property +entry.properties.delete=Delete Property \ No newline at end of file diff --git a/src/main/resources/org/jenkins/plugins/lockableresources/LockableResource/config_cs.properties b/src/main/resources/org/jenkins/plugins/lockableresources/LockableResource/config_cs.properties new file mode 100644 index 000000000..0fd70285e --- /dev/null +++ b/src/main/resources/org/jenkins/plugins/lockableresources/LockableResource/config_cs.properties @@ -0,0 +1,25 @@ +# The MIT License +# +# Copyright 2022 Martin Pokorny. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +entry.name.title=Jm\u00e9no +entry.description.title=Popis +entry.labels.title=Popisky \ No newline at end of file diff --git a/src/main/resources/org/jenkins/plugins/lockableresources/LockableResource/config_de.properties b/src/main/resources/org/jenkins/plugins/lockableresources/LockableResource/config_de.properties new file mode 100644 index 000000000..cdd76fa46 --- /dev/null +++ b/src/main/resources/org/jenkins/plugins/lockableresources/LockableResource/config_de.properties @@ -0,0 +1,25 @@ +# The MIT License +# +# Copyright 2022 Martin Pokorny. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +entry.name.title=Name +entry.description.title=Beschreibung +entry.labels.title=Labels \ No newline at end of file diff --git a/src/main/resources/org/jenkins/plugins/lockableresources/LockableResource/config_fr.properties b/src/main/resources/org/jenkins/plugins/lockableresources/LockableResource/config_fr.properties new file mode 100644 index 000000000..5e798e099 --- /dev/null +++ b/src/main/resources/org/jenkins/plugins/lockableresources/LockableResource/config_fr.properties @@ -0,0 +1,28 @@ +# The MIT License +# +# Copyright 2022 Martin Pokorny. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +entry.name.title=Nom +entry.description.title=Description +entry.labels.title=Libell\u00e9s +entry.properties.title=Propri\u00e9t\u00e9s +entry.properties.add=Ajouter une propri\u00e9t\u00e9 +entry.properties.delete=Supprimer une propri\u00e9t\u00e9 \ No newline at end of file diff --git a/src/main/resources/org/jenkins/plugins/lockableresources/LockableResource/config_sk.properties b/src/main/resources/org/jenkins/plugins/lockableresources/LockableResource/config_sk.properties new file mode 100644 index 000000000..f369e6755 --- /dev/null +++ b/src/main/resources/org/jenkins/plugins/lockableresources/LockableResource/config_sk.properties @@ -0,0 +1,25 @@ +# The MIT License +# +# Copyright 2022 Martin Pokorny. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +entry.name.title=Meno +entry.description.title=Popis +entry.labels.title=\u0160t\u00edtky \ No newline at end of file diff --git a/src/main/resources/org/jenkins/plugins/lockableresources/LockableResourceProperty/config.jelly b/src/main/resources/org/jenkins/plugins/lockableresources/LockableResourceProperty/config.jelly new file mode 100644 index 000000000..561b3f24d --- /dev/null +++ b/src/main/resources/org/jenkins/plugins/lockableresources/LockableResourceProperty/config.jelly @@ -0,0 +1,10 @@ + + + + + + + + + diff --git a/src/main/resources/org/jenkins/plugins/lockableresources/LockableResourceProperty/config.properties b/src/main/resources/org/jenkins/plugins/lockableresources/LockableResourceProperty/config.properties new file mode 100644 index 000000000..3034bf1f9 --- /dev/null +++ b/src/main/resources/org/jenkins/plugins/lockableresources/LockableResourceProperty/config.properties @@ -0,0 +1,24 @@ +# The MIT License +# +# Copyright 2022 Martin Pokorny. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +entry.name.title=Name +entry.value.title=Value \ No newline at end of file diff --git a/src/main/resources/org/jenkins/plugins/lockableresources/LockableResourceProperty/config_fr.properties b/src/main/resources/org/jenkins/plugins/lockableresources/LockableResourceProperty/config_fr.properties new file mode 100644 index 000000000..e6bfee239 --- /dev/null +++ b/src/main/resources/org/jenkins/plugins/lockableresources/LockableResourceProperty/config_fr.properties @@ -0,0 +1,24 @@ +# The MIT License +# +# Copyright 2022 Martin Pokorny. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +entry.name.title=Nom +entry.value.title=Valeur \ No newline at end of file diff --git a/src/main/resources/org/jenkins/plugins/lockableresources/LockableResourcesManager/config.jelly b/src/main/resources/org/jenkins/plugins/lockableresources/LockableResourcesManager/config.jelly index 658b7595f..6c39f6e9c 100644 --- a/src/main/resources/org/jenkins/plugins/lockableresources/LockableResourcesManager/config.jelly +++ b/src/main/resources/org/jenkins/plugins/lockableresources/LockableResourcesManager/config.jelly @@ -10,17 +10,54 @@ * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * --> - - - - - - -
-
-
-
-
-
+ xmlns:l="/lib/layout" xmlns:t="/lib/hudson" xmlns:f="/lib/form"> + + + + + + + + + + + + +
+ ${%entry.moved} + ${%entry.moved.link} +
+
+ + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+
+ +
diff --git a/src/main/resources/org/jenkins/plugins/lockableresources/LockableResourcesManager/config.properties b/src/main/resources/org/jenkins/plugins/lockableresources/LockableResourcesManager/config.properties new file mode 100644 index 000000000..ec4a18708 --- /dev/null +++ b/src/main/resources/org/jenkins/plugins/lockableresources/LockableResourcesManager/config.properties @@ -0,0 +1,40 @@ +# The MIT License +# +# Copyright 2022 Martin Pokorny. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +section.title=Lockable Resources Manager +configuration.title=Configuration +configuration.allowEmptyOrNullValues.title=Allow empty or null values +configuration.allowEphemeralResources.title=Allow ephemeral resources +entry.title=Lockable Resources +entry.moved=Resources are now managed from the dedicated Lockable Resources page under Manage Jenkins. The settings above remain here as they control global lock step behavior. +entry.moved.link=Go to Lockable Resources +field.header=Resource +field.add=Add Lockable Resource +remote.server.section.title=Remote Lockable Resources (Server) +remote.server.remoteApiEnabled.title=Enable remote API +remote.server.exposeLabel.title=Expose label(s) (space-separated) +remote.client.section.title=Remote Lockable Resources (Client) +remote.clientId.title=Client ID (optional) +remote.forcedServerId.title=Forced server ID (delegated mode, optional) +remote.remotes.title=Remote servers +remote.remotes.header=Remote server +remote.remotes.add=Add remote server diff --git a/src/main/resources/org/jenkins/plugins/lockableresources/LockableResourcesManager/config_cs.properties b/src/main/resources/org/jenkins/plugins/lockableresources/LockableResourcesManager/config_cs.properties new file mode 100644 index 000000000..9a13642fe --- /dev/null +++ b/src/main/resources/org/jenkins/plugins/lockableresources/LockableResourcesManager/config_cs.properties @@ -0,0 +1,26 @@ +# The MIT License +# +# Copyright 2022 Martin Pokorny. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +section.title=Spr\u00e1vce uzamykateln\u00fdch zdroj\u00fa +entry.title=Uzamykateln\u00e9 zdroje +field.header=Zdroj +field.add=P\u0159idat uzamykateln\u00fd zdroj \ No newline at end of file diff --git a/src/main/resources/org/jenkins/plugins/lockableresources/LockableResourcesManager/config_de.properties b/src/main/resources/org/jenkins/plugins/lockableresources/LockableResourcesManager/config_de.properties new file mode 100644 index 000000000..bc6600e47 --- /dev/null +++ b/src/main/resources/org/jenkins/plugins/lockableresources/LockableResourcesManager/config_de.properties @@ -0,0 +1,26 @@ +# The MIT License +# +# Copyright 2022 Martin Pokorny. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +section.title=Sperrbare Ressourcen verwalten +entry.title=Sperrbare Ressourcen +field.header=Ressource +field.add=Sperrbare Ressource hinzuf\u00fcgen \ No newline at end of file diff --git a/src/main/resources/org/jenkins/plugins/lockableresources/LockableResourcesManager/config_fr.properties b/src/main/resources/org/jenkins/plugins/lockableresources/LockableResourcesManager/config_fr.properties new file mode 100644 index 000000000..73d5c9017 --- /dev/null +++ b/src/main/resources/org/jenkins/plugins/lockableresources/LockableResourcesManager/config_fr.properties @@ -0,0 +1,26 @@ +# The MIT License +# +# Copyright 2022 Martin Pokorny. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +section.title=Gestionnaire des ressources verrouillables +entry.title=Ressources verrouillables +field.header=Ressource +field.add=Ajouter une ressource verrouillable \ No newline at end of file diff --git a/src/main/resources/org/jenkins/plugins/lockableresources/LockableResourcesManager/config_sk.properties b/src/main/resources/org/jenkins/plugins/lockableresources/LockableResourcesManager/config_sk.properties new file mode 100644 index 000000000..dd4817dd3 --- /dev/null +++ b/src/main/resources/org/jenkins/plugins/lockableresources/LockableResourcesManager/config_sk.properties @@ -0,0 +1,26 @@ +# The MIT License +# +# Copyright 2022 Martin Pokorny. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +section.title=Spr\u00e1vca uzamykate\u013en\u00fdch zdrojov +entry.title=Uzamykate\u013en\u00e9 zdroje +field.header=Zdroj +field.add=Prida\u0165 uzamykate\u013en\u00fd zdroj \ No newline at end of file diff --git a/src/main/resources/org/jenkins/plugins/lockableresources/LockableResourcesManager/help-allowEmptyOrNullValues.html b/src/main/resources/org/jenkins/plugins/lockableresources/LockableResourcesManager/help-allowEmptyOrNullValues.html new file mode 100644 index 000000000..7b121ca58 --- /dev/null +++ b/src/main/resources/org/jenkins/plugins/lockableresources/LockableResourcesManager/help-allowEmptyOrNullValues.html @@ -0,0 +1,9 @@ +
+ Usage at own risk. Enabling this flag allows calling the lock step +
    +
  • with null or empty string
  • +
  • with label property with null or empty value
  • +
  • with resource property with null or empty value
  • +
  • with extra property with null or empty list
  • +
+
diff --git a/src/main/resources/org/jenkins/plugins/lockableresources/LockableResourcesManager/help-allowEphemeralResources.html b/src/main/resources/org/jenkins/plugins/lockableresources/LockableResourcesManager/help-allowEphemeralResources.html new file mode 100644 index 000000000..20fa88798 --- /dev/null +++ b/src/main/resources/org/jenkins/plugins/lockableresources/LockableResourcesManager/help-allowEphemeralResources.html @@ -0,0 +1,14 @@ +
+ Controls whether ephemeral resources can be created automatically. +
    +
  • Enabled (default): When a lock step references a resource name that does not exist, + the plugin automatically creates a temporary (ephemeral) resource for the duration of the build.
  • +
  • Disabled: Locking a non-existent resource will cause the job to block until the + resource is manually created. This prevents accidental creation of resources due to typos + or misconfiguration.
  • +
+

+ Note: This setting only affects resources referenced by name. Resources selected by + label are not affected. +

+
diff --git a/src/main/resources/org/jenkins/plugins/lockableresources/LockableResourcesManager/help-clientId.html b/src/main/resources/org/jenkins/plugins/lockableresources/LockableResourcesManager/help-clientId.html new file mode 100644 index 000000000..daca29b20 --- /dev/null +++ b/src/main/resources/org/jenkins/plugins/lockableresources/LockableResourcesManager/help-clientId.html @@ -0,0 +1,4 @@ +
+ Identifier sent to remote servers in POST /acquire as clientId. + If left empty, this plugin falls back to Jenkins Root URL. +
diff --git a/src/main/resources/org/jenkins/plugins/lockableresources/LockableResourcesManager/help-exposeLabel.html b/src/main/resources/org/jenkins/plugins/lockableresources/LockableResourcesManager/help-exposeLabel.html new file mode 100644 index 000000000..c33b7bc18 --- /dev/null +++ b/src/main/resources/org/jenkins/plugins/lockableresources/LockableResourcesManager/help-exposeLabel.html @@ -0,0 +1,10 @@ +
+ Label(s) used to expose resources through the remote API. +
+ Space-separated list of labels: a resource is available for remote acquire if it carries + any of these labels. For example gpu license exposes every resource + labelled gpu or license; a single label such as remote-ok + exposes only the resources you explicitly tag with it. +
+ Leave empty to expose nothing (opt-in behavior). +
diff --git a/src/main/resources/org/jenkins/plugins/lockableresources/LockableResourcesManager/help-forcedServerId.html b/src/main/resources/org/jenkins/plugins/lockableresources/LockableResourcesManager/help-forcedServerId.html new file mode 100644 index 000000000..0f6d18140 --- /dev/null +++ b/src/main/resources/org/jenkins/plugins/lockableresources/LockableResourcesManager/help-forcedServerId.html @@ -0,0 +1,17 @@ +
+

+ When set, every lock() step on this controller is transparently delegated to + the remote server identified by this ID (delegated mode), regardless of whether + the pipeline DSL specifies a serverId parameter or not. +

+

+ If the DSL also specifies a serverId that differs from this value, the forced + server ID takes precedence and an INFO log message is written to the build log. +

+

+ The value must match the Server ID of one of the configured remote servers + listed below. Leave blank to disable delegated mode (default: peer mode only). +

+

Example: set to prod-controller to route all locks to the + remote server whose Server ID is prod-controller.

+
diff --git a/src/main/resources/org/jenkins/plugins/lockableresources/LockableResourcesManager/help-remoteApiEnabled.html b/src/main/resources/org/jenkins/plugins/lockableresources/LockableResourcesManager/help-remoteApiEnabled.html new file mode 100644 index 000000000..ecf193ff2 --- /dev/null +++ b/src/main/resources/org/jenkins/plugins/lockableresources/LockableResourcesManager/help-remoteApiEnabled.html @@ -0,0 +1,5 @@ +
+ Enables or disables the remote lock REST API on this Jenkins controller. +
+ When disabled, all /lockable-resources/remote/v1/* endpoints return HTTP 403. +
diff --git a/src/main/resources/org/jenkins/plugins/lockableresources/LockableResourcesManager/help-remotes.html b/src/main/resources/org/jenkins/plugins/lockableresources/LockableResourcesManager/help-remotes.html new file mode 100644 index 000000000..b008b98d7 --- /dev/null +++ b/src/main/resources/org/jenkins/plugins/lockableresources/LockableResourcesManager/help-remotes.html @@ -0,0 +1,5 @@ +
+ List of remote Jenkins servers that can be targeted by lock(..., serverId: 'X'). +
+ Each entry defines a client-side alias, remote URL, and optional credentialsId. +
diff --git a/src/main/resources/org/jenkins/plugins/lockableresources/Messages.properties b/src/main/resources/org/jenkins/plugins/lockableresources/Messages.properties index f634f92cb..36301177b 100644 --- a/src/main/resources/org/jenkins/plugins/lockableresources/Messages.properties +++ b/src/main/resources/org/jenkins/plugins/lockableresources/Messages.properties @@ -8,9 +8,95 @@ # * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * # LockableResourcesRootAction.PermissionGroup=Lockable Resources -LockableResourcesRootAction.UnlockPermission=Unlock +LockableResourcesRootAction.ManagementLink.Description=View and manage lockable resources, labels, and the lock queue. LockableResourcesRootAction.UnlockPermission.Description=This permission grants the ability to manually \ unlock resources that have been locked by builds. -LockableResourcesRootAction.ReservePermission=Reserve LockableResourcesRootAction.ReservePermission.Description=This permission grants the ability to manually \ reserve lockable resources outside of a build. +LockableResourcesRootAction.StealPermission.Description=This permission grants the ability to manually \ + "steal" resources that have been locked by builds or "reassign" those reserved by users. +LockableResourcesRootAction.ViewPermission.Description=This permission grants the ability to view \ + lockable resources. +LockableResourcesRootAction.QueueChangeOrderPermission.Description=This permission grants the ability to \ + manually manipulate the lockable resources queue.. +LockableResourcesRootAction.ConfigurePermission.Description=This permission grants the ability to \ + create, edit, and delete lockable resources. +LockableResourcesRootAction.RemoteUsePermission.Description=This permission grants the ability to \ + acquire and release locks through the remote lock REST API. Grant it to the machine users whose \ + API tokens are used by remote client controllers. +LockedResourcesBuildAction.displayName=Lockable resources +# Dialog text used by the client-side resource editor +resource.editor.property.name.placeholder=Name +resource.editor.property.value.placeholder=Value +resource.editor.property.remove.label=Remove property +resource.editor.loading=loading... +resource.editor.label.prefix=label: {0} +resource.editor.selectAll=Select all +resource.editor.selectOne=Select {0} +resource.editor.pageSizeAll=All +resource.editor.colvis.all=All +resource.editor.colvis.none=None +resource.editor.labelExpressionFilter.error=Failed to apply label expression filter: {0} +resource.editor.queue.loading=Loading... +resource.editor.table.column.index=# +resource.editor.table.column.resource=Resource +resource.editor.table.column.status=Status +resource.editor.table.column.heldBy=Held By +resource.editor.table.column.reason=Reason +resource.editor.table.column.since=Since +resource.editor.table.column.labels=Labels +resource.editor.table.column.properties=Properties +resource.editor.table.column.actions=Actions +resource.editor.labels.table.column.labels=Labels +resource.editor.labels.table.column.assigned=Assigned Resources +resource.editor.labels.table.column.free=Available +resource.editor.labels.table.column.percentage=Availability % +resource.editor.queue.table.column.position=Position +resource.editor.queue.table.column.action=Reorder +resource.editor.queue.table.column.type=Type +resource.editor.queue.table.column.request=Resource(s) +resource.editor.queue.table.column.reason=Reason +resource.editor.queue.table.column.requestedBy=Requested By +resource.editor.queue.table.column.requestedAt=Queued At +resource.editor.queue.table.column.priority=Priority +resource.editor.queue.table.column.queueId=Queue ID +# Java errors +error.labelDoesNotExist=The resource label does not exist: {0}. +error.resourceDoesNotExist=The resource does not exist: {0}. +error.resourceAlreadyExists=A resource with the name ''{0}'' already exists. +error.labelOrNameMustBeSpecified=Either resource label or resource name must be specified. +error.labelAndNameSpecified=Resource label and resource name cannot be specified simultaneously. +error.labelAndNameOrGroovySpecified=Only resource label, groovy expression, or resource names can be defined, not more than one. +error.couldNotParseToint=Could not parse the given value as integer. +error.givenAmountIsGreaterThatResourcesAmount=Given amount %d is greater than amount of resources: %d. +error.resourceAlreadyLocked=Resource {0} already reserved or locked! +error.notAuthenticated=You must be authenticated to perform this action. +error.invalidResourceSelectionStrategy=The strategy "{0}" is not supported. Valid options are {1}. +error.isNotANumber=The queue position must be a number. Given: {0} +error.queuePositionOutOfRange=The queue position {0} is out of range (1 - {1})! +error.queueDoesNotExist=The queue {0} does not (anymore) exist. +error.inversePrecedenceAndPriorityAreSet=The "inverse precedence" option is not compatible with "queue priority" option! +# display-names +LockStep.displayName=Lock shared resource +LockStepResource.displayName=Resource +LockableResource.displayName=Resource +LockableResourcesManager.displayName=External Resources +RequiredResourcesProperty.displayName=Required Lockable Resources +# warnings (build-parameter references) +warning.forcedServerIdNotConfigured=forcedServerId ''{0}'' does not match any configured remote connection. \ + Delegated mode will fail until a remote with this serverId is added. +warning.resourceNameContainsVariable=Resource name contains build parameter references. \ + Validation will occur at build time. +warning.labelContainsVariable=Label contains build parameter references. \ + Validation will occur at build time. +warning.resourceNumberContainsVariable=Resource number contains build parameter references. \ + Validation will occur at build time. +UpdateLockStep.displayName=Update lockable resource +# UpdateLockStep errors +UpdateLockStep.error.resourceRequired=The resource name must be specified. +UpdateLockStep.error.resourceNotFound=Resource {0} does not exist. Use createResource: true to create it. +UpdateLockStep.error.resourceInUse=Resource {0} is currently locked or queued and cannot be deleted. +UpdateLockStep.error.resourceReserved=Resource {0} is currently reserved and cannot be deleted. +UpdateLockStep.error.deleteAndCreateConflict=Cannot specify both deleteResource and createResource. +UpdateLockStep.error.deleteWithOtherOptions=When deleteResource is true, other modification options cannot be specified. +UpdateLockStep.error.setLabelsConflict=Cannot use setLabels together with addLabels or removeLabels. diff --git a/src/main/resources/org/jenkins/plugins/lockableresources/Messages_cs.properties b/src/main/resources/org/jenkins/plugins/lockableresources/Messages_cs.properties new file mode 100644 index 000000000..0e4a912ec --- /dev/null +++ b/src/main/resources/org/jenkins/plugins/lockableresources/Messages_cs.properties @@ -0,0 +1,96 @@ +# * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * # +# Copyright (c) 2014, 6WIND S.A. All rights reserved. # +# # +# This file is part of the Jenkins Lockable Resources Plugin and is # +# published under the MIT license. # +# # +# See the "LICENSE.txt" file for more information. # +# * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * # + +LockableResourcesRootAction.PermissionGroup=Uzamykateln\u00e9 zdroje +LockableResourcesRootAction.UnlockPermission.Description=Toto opr\u00e1vn\u011bn\u00ed ud\u011bluje mo\u017enost ru\u010dn\u011b odemknout zdroje, kter\u00e9 byly uzam\u010deny sestaven\u00edmi. +LockableResourcesRootAction.ReservePermission.Description=Toto opr\u00e1vn\u011bn\u00ed poskytuje mo\u017enost ru\u010dn\u011b rezervovat uzamykateln\u00e9 zdroje mimo sestaven\u00ed. +LockableResourcesRootAction.StealPermission.Description=Toto opr\u00e1vn\u011bn\u00ed ud\u011bluje mo\u017enost manu\u00e1ln\u011b "ukradnout" zdroje, kter\u00e9 byly uzam\u010deny sestaven\u00edm nebo "p\u0159e\u0159adit" polo\u017eky rezervovan\u00e9 u\u017eivateli. +LockableResourcesRootAction.ViewPermission.Description=Toto opr\u00e1vn\u011bn\u00ed ud\u011bluje mo\u017enost zobrazit uzamykateln\u00e9 zdroje. + + +# TODO: missing translations; fallback to English until translated via Crowdin +LockableResourcesRootAction.ManagementLink.Description=View and manage lockable resources, labels, and the lock queue. +LockableResourcesRootAction.QueueChangeOrderPermission.Description=This permission grants the ability to \ + manually manipulate the lockable resources queue.. +LockableResourcesRootAction.ConfigurePermission.Description=This permission grants the ability to \ + create, edit, and delete lockable resources. +LockableResourcesRootAction.RemoteUsePermission.Description=This permission grants the ability to \ + acquire and release locks through the remote lock REST API. Grant it to the machine users whose \ + API tokens are used by remote client controllers. +LockedResourcesBuildAction.displayName=Lockable resources +error.labelDoesNotExist=The resource label does not exist: {0}. +error.resourceDoesNotExist=The resource does not exist: {0}. +error.resourceAlreadyExists=A resource with the name ''{0}'' already exists. +error.labelOrNameMustBeSpecified=Either resource label or resource name must be specified. +error.labelAndNameSpecified=Resource label and resource name cannot be specified simultaneously. +error.labelAndNameOrGroovySpecified=Only resource label, groovy expression, or resource names can be defined, not more than one. +error.couldNotParseToint=Could not parse the given value as integer. +error.givenAmountIsGreaterThatResourcesAmount=Given amount %d is greater than amount of resources: %d. +error.resourceAlreadyLocked=Resource {0} already reserved or locked! +error.notAuthenticated=You must be authenticated to perform this action. +error.invalidResourceSelectionStrategy=The strategy "{0}" is not supported. Valid options are {1}. +error.isNotANumber=The queue position must be a number. Given: {0} +error.queuePositionOutOfRange=The queue position {0} is out of range (1 - {1})! +error.queueDoesNotExist=The queue {0} does not (anymore) exist. +error.inversePrecedenceAndPriorityAreSet=The "inverse precedence" option is not compatible with "queue priority" option! +LockStep.displayName=Lock shared resource +LockStepResource.displayName=Resource +LockableResource.displayName=Resource +LockableResourcesManager.displayName=External Resources +RequiredResourcesProperty.displayName=Required Lockable Resources +warning.forcedServerIdNotConfigured=forcedServerId ''{0}'' does not match any configured remote connection. \ + Delegated mode will fail until a remote with this serverId is added. +warning.resourceNameContainsVariable=Resource name contains build parameter references. \ + Validation will occur at build time. +warning.labelContainsVariable=Label contains build parameter references. \ + Validation will occur at build time. +warning.resourceNumberContainsVariable=Resource number contains build parameter references. \ + Validation will occur at build time. +UpdateLockStep.displayName=Update lockable resource +UpdateLockStep.error.resourceRequired=The resource name must be specified. +UpdateLockStep.error.resourceNotFound=Resource {0} does not exist. Use createResource: true to create it. +UpdateLockStep.error.resourceInUse=Resource {0} is currently locked or queued and cannot be deleted. +UpdateLockStep.error.resourceReserved=Resource {0} is currently reserved and cannot be deleted. +UpdateLockStep.error.deleteAndCreateConflict=Cannot specify both deleteResource and createResource. +UpdateLockStep.error.deleteWithOtherOptions=When deleteResource is true, other modification options cannot be specified. +UpdateLockStep.error.setLabelsConflict=Cannot use setLabels together with addLabels or removeLabels. +resource.editor.property.name.placeholder=Name +resource.editor.property.value.placeholder=Value +resource.editor.property.remove.label=Remove property +resource.editor.loading=loading... +resource.editor.label.prefix=label: {0} +resource.editor.selectAll=Select all +resource.editor.selectOne=Select {0} +resource.editor.pageSizeAll=All +resource.editor.colvis.all=All +resource.editor.colvis.none=None +resource.editor.labelExpressionFilter.error=Failed to apply label expression filter: {0} +resource.editor.queue.loading=Loading... +resource.editor.table.column.index=# +resource.editor.table.column.resource=Resource +resource.editor.table.column.status=Status +resource.editor.table.column.heldBy=Held By +resource.editor.table.column.reason=Reason +resource.editor.table.column.since=Since +resource.editor.table.column.labels=Labels +resource.editor.table.column.properties=Properties +resource.editor.table.column.actions=Actions +resource.editor.labels.table.column.labels=Labels +resource.editor.labels.table.column.assigned=Assigned Resources +resource.editor.labels.table.column.free=Available +resource.editor.labels.table.column.percentage=Availability % +resource.editor.queue.table.column.position=Position +resource.editor.queue.table.column.action=Reorder +resource.editor.queue.table.column.type=Type +resource.editor.queue.table.column.request=Resource(s) +resource.editor.queue.table.column.reason=Reason +resource.editor.queue.table.column.requestedBy=Requested By +resource.editor.queue.table.column.requestedAt=Queued At +resource.editor.queue.table.column.priority=Priority +resource.editor.queue.table.column.queueId=Queue ID diff --git a/src/main/resources/org/jenkins/plugins/lockableresources/Messages_de.properties b/src/main/resources/org/jenkins/plugins/lockableresources/Messages_de.properties new file mode 100644 index 000000000..182af6aec --- /dev/null +++ b/src/main/resources/org/jenkins/plugins/lockableresources/Messages_de.properties @@ -0,0 +1,97 @@ +# * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * # +# Copyright (c) 2014, 6WIND S.A. All rights reserved. # +# # +# This file is part of the Jenkins Lockable Resources Plugin and is # +# published under the MIT license. # +# # +# See the "LICENSE.txt" file for more information. # +# * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * # + +LockableResourcesRootAction.PermissionGroup=Sperrbare Ressourcen +LockableResourcesRootAction.ManagementLink.Description=Sperrbare Ressourcen, Labels und die Sperrwarteschlange anzeigen und verwalten. +LockableResourcesRootAction.UnlockPermission.Description=Diese Berechtigung gew\u00e4hrt die M\u00f6glichkeit, Ressourcen manuell freizuschalten, die durch Builds gesperrt wurden. +LockableResourcesRootAction.ReservePermission.Description=Diese Berechtigung gew\u00e4hrt die M\u00f6glichkeit, sperrbare Ressourcen au\u00dferhalb eines Builds manuell zu reservieren. +LockableResourcesRootAction.StealPermission.Description=Diese Berechtigung gew\u00e4hrt die M\u00f6glichkeit, Ressourcen manuell zu "stehlen", die durch Builds gesperrt wurden, oder von Benutzern reservierte Ressourcen "neu zuzuweisen". +LockableResourcesRootAction.ViewPermission.Description=Diese Berechtigung gew\u00e4hrt die M\u00f6glichkeit, sperrbare Ressourcen anzuzeigen. +LockableResourcesRootAction.QueueChangeOrderPermission.Description=Diese Berechtigung gew\u00e4hrt die M\u00f6glichkeit, \ + die Warteschlange der sperrbaren Ressourcen manuell zu bearbeiten. +LockableResourcesRootAction.ConfigurePermission.Description=Diese Berechtigung gew\u00e4hrt die M\u00f6glichkeit, \ + sperrbare Ressourcen zu erstellen, zu bearbeiten und zu l\u00f6schen. +LockableResourcesRootAction.RemoteUsePermission.Description=Diese Berechtigung gew\u00e4hrt die M\u00f6glichkeit, \ + Sperren \u00fcber die Remote-Lock-REST-API zu erwerben und freizugeben. Sie sollte Maschinenbenutzern \ + erteilt werden, deren API-Tokens von entfernten Client-Controllern verwendet werden. +LockedResourcesBuildAction.displayName=Sperrbare Ressourcen +# Java errors +error.labelDoesNotExist=Das Ressourcen-Label existiert nicht: {0}. +error.resourceDoesNotExist=Die Ressource existiert nicht: {0}. +error.resourceAlreadyExists=Eine Ressource mit dem Namen ''{0}'' existiert bereits. +error.labelOrNameMustBeSpecified=Es muss entweder ein Ressourcen-Label oder ein Ressourcenname angegeben werden. +error.labelAndNameSpecified=Ressourcen-Label und Ressourcenname k\u00f6nnen nicht gleichzeitig angegeben werden. +error.labelAndNameOrGroovySpecified=Es kann nur ein Ressourcen-Label, ein Groovy-Ausdruck oder ein Ressourcenname angegeben werden, nicht mehrere gleichzeitig. +error.couldNotParseToint=Der angegebene Wert konnte nicht als Ganzzahl geparst werden. +error.givenAmountIsGreaterThatResourcesAmount=Die angegebene Anzahl %d ist gr\u00f6\u00dfer als die Anzahl der Ressourcen: %d. +error.resourceAlreadyLocked=Ressource {0} ist bereits reserviert oder gesperrt! +error.notAuthenticated=Sie m\u00fcssen authentifiziert sein, um diese Aktion auszuf\u00fchren. +error.invalidResourceSelectionStrategy=Die Strategie "{0}" wird nicht unterst\u00fctzt. G\u00fcltige Optionen sind {1}. +error.isNotANumber=Die Warteschlangenposition muss eine Zahl sein. Angegeben: {0} +error.queuePositionOutOfRange=Die Warteschlangenposition {0} liegt au\u00dferhalb des g\u00fcltigen Bereichs (1 - {1})! +error.queueDoesNotExist=Die Warteschlange {0} existiert (nicht mehr). +error.inversePrecedenceAndPriorityAreSet=Die Option "inverse precedence" ist nicht mit der Option "queue priority" kompatibel! +# display-names +LockStep.displayName=Gemeinsame Ressource sperren +LockStepResource.displayName=Ressource +LockableResource.displayName=Ressource +LockableResourcesManager.displayName=Externe Ressourcen +RequiredResourcesProperty.displayName=Erforderliche sperrbare Ressourcen +# warnings (build-parameter references) +warning.forcedServerIdNotConfigured=forcedServerId ''{0}'' stimmt mit keiner konfigurierten Remote-Verbindung \u00fcberein. \ + Der delegierte Modus schl\u00e4gt fehl, bis eine Remote-Verbindung mit dieser serverId hinzugef\u00fcgt wird. +warning.resourceNameContainsVariable=Der Ressourcenname enth\u00e4lt Build-Parameterreferenzen. \ + Die Validierung erfolgt zur Build-Laufzeit. +warning.labelContainsVariable=Das Label enth\u00e4lt Build-Parameterreferenzen. \ + Die Validierung erfolgt zur Build-Laufzeit. +warning.resourceNumberContainsVariable=Die Ressourcennummer enth\u00e4lt Build-Parameterreferenzen. \ + Die Validierung erfolgt zur Build-Laufzeit. +UpdateLockStep.displayName=Sperrbare Ressource aktualisieren +# UpdateLockStep errors +UpdateLockStep.error.resourceRequired=Der Ressourcenname muss angegeben werden. +UpdateLockStep.error.resourceNotFound=Ressource {0} existiert nicht. Verwenden Sie createResource: true, um sie zu erstellen. +UpdateLockStep.error.resourceInUse=Ressource {0} ist derzeit gesperrt oder in der Warteschlange und kann nicht gel\u00f6scht werden. +UpdateLockStep.error.resourceReserved=Ressource {0} ist derzeit reserviert und kann nicht gel\u00f6scht werden. +UpdateLockStep.error.deleteAndCreateConflict=deleteResource und createResource k\u00f6nnen nicht gleichzeitig angegeben werden. +UpdateLockStep.error.deleteWithOtherOptions=Wenn deleteResource true ist, k\u00f6nnen keine weiteren \u00c4nderungsoptionen angegeben werden. +UpdateLockStep.error.setLabelsConflict=setLabels kann nicht zusammen mit addLabels oder removeLabels verwendet werden. +resource.editor.property.name.placeholder=Name +resource.editor.property.value.placeholder=Value +resource.editor.property.remove.label=Remove property +resource.editor.loading=loading... +resource.editor.label.prefix=label: {0} +resource.editor.selectAll=Select all +resource.editor.selectOne=Select {0} +resource.editor.pageSizeAll=All +resource.editor.colvis.all=All +resource.editor.colvis.none=None +resource.editor.labelExpressionFilter.error=Failed to apply label expression filter: {0} +resource.editor.queue.loading=Loading... +resource.editor.table.column.index=# +resource.editor.table.column.resource=Resource +resource.editor.table.column.status=Status +resource.editor.table.column.heldBy=Held By +resource.editor.table.column.reason=Reason +resource.editor.table.column.since=Since +resource.editor.table.column.labels=Labels +resource.editor.table.column.properties=Properties +resource.editor.table.column.actions=Actions +resource.editor.labels.table.column.labels=Labels +resource.editor.labels.table.column.assigned=Assigned Resources +resource.editor.labels.table.column.free=Available +resource.editor.labels.table.column.percentage=Availability % +resource.editor.queue.table.column.position=Position +resource.editor.queue.table.column.action=Reorder +resource.editor.queue.table.column.type=Type +resource.editor.queue.table.column.request=Resource(s) +resource.editor.queue.table.column.reason=Reason +resource.editor.queue.table.column.requestedBy=Requested By +resource.editor.queue.table.column.requestedAt=Queued At +resource.editor.queue.table.column.priority=Priority +resource.editor.queue.table.column.queueId=Queue ID diff --git a/src/main/resources/org/jenkins/plugins/lockableresources/Messages_fr.properties b/src/main/resources/org/jenkins/plugins/lockableresources/Messages_fr.properties new file mode 100644 index 000000000..bf14e86cf --- /dev/null +++ b/src/main/resources/org/jenkins/plugins/lockableresources/Messages_fr.properties @@ -0,0 +1,96 @@ +# * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * # +# Copyright (c) 2014, 6WIND S.A. All rights reserved. # +# # +# This file is part of the Jenkins Lockable Resources Plugin and is # +# published under the MIT license. # +# # +# See the "LICENSE.txt" file for more information. # +# * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * # + +LockableResourcesRootAction.PermissionGroup=Ressources verrouillables +LockableResourcesRootAction.UnlockPermission.Description=Cette permission accorde la possibilit\u00e9 de d\u00e9verrouiller manuellement les ressources qui ont \u00e9t\u00e9 verrouill\u00e9es par les builds. +LockableResourcesRootAction.ReservePermission.Description=Cette permission accorde la possibilit\u00e9 de r\u00e9server manuellement les ressources verrouillables \u00e0 l'ext\u00e9rieur d'un build. +LockableResourcesRootAction.StealPermission.Description=Cette permission permet de "voler" manuellement les ressources qui ont \u00e9t\u00e9 verrouill\u00e9es par les builds ou de "r\u00e9assigner" celles r\u00e9serv\u00e9es par les utilisateurs. +LockableResourcesRootAction.ViewPermission.Description=Cette permission accorde la possibilit\u00e9 de voir les ressources verrouillables. + + +# TODO: missing translations; fallback to English until translated via Crowdin +LockableResourcesRootAction.ManagementLink.Description=View and manage lockable resources, labels, and the lock queue. +LockableResourcesRootAction.QueueChangeOrderPermission.Description=This permission grants the ability to \ + manually manipulate the lockable resources queue.. +LockableResourcesRootAction.ConfigurePermission.Description=This permission grants the ability to \ + create, edit, and delete lockable resources. +LockableResourcesRootAction.RemoteUsePermission.Description=This permission grants the ability to \ + acquire and release locks through the remote lock REST API. Grant it to the machine users whose \ + API tokens are used by remote client controllers. +LockedResourcesBuildAction.displayName=Lockable resources +error.labelDoesNotExist=The resource label does not exist: {0}. +error.resourceDoesNotExist=The resource does not exist: {0}. +error.resourceAlreadyExists=A resource with the name ''{0}'' already exists. +error.labelOrNameMustBeSpecified=Either resource label or resource name must be specified. +error.labelAndNameSpecified=Resource label and resource name cannot be specified simultaneously. +error.labelAndNameOrGroovySpecified=Only resource label, groovy expression, or resource names can be defined, not more than one. +error.couldNotParseToint=Could not parse the given value as integer. +error.givenAmountIsGreaterThatResourcesAmount=Given amount %d is greater than amount of resources: %d. +error.resourceAlreadyLocked=Resource {0} already reserved or locked! +error.notAuthenticated=You must be authenticated to perform this action. +error.invalidResourceSelectionStrategy=The strategy "{0}" is not supported. Valid options are {1}. +error.isNotANumber=The queue position must be a number. Given: {0} +error.queuePositionOutOfRange=The queue position {0} is out of range (1 - {1})! +error.queueDoesNotExist=The queue {0} does not (anymore) exist. +error.inversePrecedenceAndPriorityAreSet=The "inverse precedence" option is not compatible with "queue priority" option! +LockStep.displayName=Lock shared resource +LockStepResource.displayName=Resource +LockableResource.displayName=Resource +LockableResourcesManager.displayName=External Resources +RequiredResourcesProperty.displayName=Required Lockable Resources +warning.forcedServerIdNotConfigured=forcedServerId ''{0}'' does not match any configured remote connection. \ + Delegated mode will fail until a remote with this serverId is added. +warning.resourceNameContainsVariable=Resource name contains build parameter references. \ + Validation will occur at build time. +warning.labelContainsVariable=Label contains build parameter references. \ + Validation will occur at build time. +warning.resourceNumberContainsVariable=Resource number contains build parameter references. \ + Validation will occur at build time. +UpdateLockStep.displayName=Update lockable resource +UpdateLockStep.error.resourceRequired=The resource name must be specified. +UpdateLockStep.error.resourceNotFound=Resource {0} does not exist. Use createResource: true to create it. +UpdateLockStep.error.resourceInUse=Resource {0} is currently locked or queued and cannot be deleted. +UpdateLockStep.error.resourceReserved=Resource {0} is currently reserved and cannot be deleted. +UpdateLockStep.error.deleteAndCreateConflict=Cannot specify both deleteResource and createResource. +UpdateLockStep.error.deleteWithOtherOptions=When deleteResource is true, other modification options cannot be specified. +UpdateLockStep.error.setLabelsConflict=Cannot use setLabels together with addLabels or removeLabels. +resource.editor.property.name.placeholder=Name +resource.editor.property.value.placeholder=Value +resource.editor.property.remove.label=Remove property +resource.editor.loading=loading... +resource.editor.label.prefix=label: {0} +resource.editor.selectAll=Select all +resource.editor.selectOne=Select {0} +resource.editor.pageSizeAll=All +resource.editor.colvis.all=All +resource.editor.colvis.none=None +resource.editor.labelExpressionFilter.error=Failed to apply label expression filter: {0} +resource.editor.queue.loading=Loading... +resource.editor.table.column.index=# +resource.editor.table.column.resource=Resource +resource.editor.table.column.status=Status +resource.editor.table.column.heldBy=Held By +resource.editor.table.column.reason=Reason +resource.editor.table.column.since=Since +resource.editor.table.column.labels=Labels +resource.editor.table.column.properties=Properties +resource.editor.table.column.actions=Actions +resource.editor.labels.table.column.labels=Labels +resource.editor.labels.table.column.assigned=Assigned Resources +resource.editor.labels.table.column.free=Available +resource.editor.labels.table.column.percentage=Availability % +resource.editor.queue.table.column.position=Position +resource.editor.queue.table.column.action=Reorder +resource.editor.queue.table.column.type=Type +resource.editor.queue.table.column.request=Resource(s) +resource.editor.queue.table.column.reason=Reason +resource.editor.queue.table.column.requestedBy=Requested By +resource.editor.queue.table.column.requestedAt=Queued At +resource.editor.queue.table.column.priority=Priority +resource.editor.queue.table.column.queueId=Queue ID diff --git a/src/main/resources/org/jenkins/plugins/lockableresources/Messages_sk.properties b/src/main/resources/org/jenkins/plugins/lockableresources/Messages_sk.properties new file mode 100644 index 000000000..24aff573f --- /dev/null +++ b/src/main/resources/org/jenkins/plugins/lockableresources/Messages_sk.properties @@ -0,0 +1,96 @@ +# * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * # +# Copyright (c) 2014, 6WIND S.A. All rights reserved. # +# # +# This file is part of the Jenkins Lockable Resources Plugin and is # +# published under the MIT license. # +# # +# See the "LICENSE.txt" file for more information. # +# * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * # + +LockableResourcesRootAction.PermissionGroup=Uzamykate\u013en\u00e9 zdroje +LockableResourcesRootAction.UnlockPermission.Description=Toto opr\u00e1vnenie ude\u013euje mo\u017enos\u0165 ru\u010dne odomkn\u00fa\u0165 zdroje, ktor\u00e9 boli uzamknut\u00e9 zostaveniami. +LockableResourcesRootAction.ReservePermission.Description=Toto opr\u00e1vnenie poskytuje mo\u017enos\u0165 ru\u010dne rezervova\u0165 uzamykate\u013en\u00e9 zdroje mimo zostavenia. +LockableResourcesRootAction.StealPermission.Description=Toto opr\u00e1vnenie umo\u017e\u0148uje manu\u00e1lne "ukradn\u00fa\u0165" zdroj, ktor\u00fd bol uzamknut\u00fd zostaven\u00edm alebo "preradi\u0165" zdroje rezervovan\u00e9 u\u017e\u00edvate\u013eom. +LockableResourcesRootAction.ViewPermission.Description=Toto opr\u00e1vnenie ude\u013euje mo\u017enos\u0165 zobrazi\u0165 zamykate\u013en\u00e9 zdroje. + + +# TODO: missing translations; fallback to English until translated via Crowdin +LockableResourcesRootAction.ManagementLink.Description=View and manage lockable resources, labels, and the lock queue. +LockableResourcesRootAction.QueueChangeOrderPermission.Description=This permission grants the ability to \ + manually manipulate the lockable resources queue.. +LockableResourcesRootAction.ConfigurePermission.Description=This permission grants the ability to \ + create, edit, and delete lockable resources. +LockableResourcesRootAction.RemoteUsePermission.Description=This permission grants the ability to \ + acquire and release locks through the remote lock REST API. Grant it to the machine users whose \ + API tokens are used by remote client controllers. +LockedResourcesBuildAction.displayName=Lockable resources +error.labelDoesNotExist=The resource label does not exist: {0}. +error.resourceDoesNotExist=The resource does not exist: {0}. +error.resourceAlreadyExists=A resource with the name ''{0}'' already exists. +error.labelOrNameMustBeSpecified=Either resource label or resource name must be specified. +error.labelAndNameSpecified=Resource label and resource name cannot be specified simultaneously. +error.labelAndNameOrGroovySpecified=Only resource label, groovy expression, or resource names can be defined, not more than one. +error.couldNotParseToint=Could not parse the given value as integer. +error.givenAmountIsGreaterThatResourcesAmount=Given amount %d is greater than amount of resources: %d. +error.resourceAlreadyLocked=Resource {0} already reserved or locked! +error.notAuthenticated=You must be authenticated to perform this action. +error.invalidResourceSelectionStrategy=The strategy "{0}" is not supported. Valid options are {1}. +error.isNotANumber=The queue position must be a number. Given: {0} +error.queuePositionOutOfRange=The queue position {0} is out of range (1 - {1})! +error.queueDoesNotExist=The queue {0} does not (anymore) exist. +error.inversePrecedenceAndPriorityAreSet=The "inverse precedence" option is not compatible with "queue priority" option! +LockStep.displayName=Lock shared resource +LockStepResource.displayName=Resource +LockableResource.displayName=Resource +LockableResourcesManager.displayName=External Resources +RequiredResourcesProperty.displayName=Required Lockable Resources +warning.forcedServerIdNotConfigured=forcedServerId ''{0}'' does not match any configured remote connection. \ + Delegated mode will fail until a remote with this serverId is added. +warning.resourceNameContainsVariable=Resource name contains build parameter references. \ + Validation will occur at build time. +warning.labelContainsVariable=Label contains build parameter references. \ + Validation will occur at build time. +warning.resourceNumberContainsVariable=Resource number contains build parameter references. \ + Validation will occur at build time. +UpdateLockStep.displayName=Update lockable resource +UpdateLockStep.error.resourceRequired=The resource name must be specified. +UpdateLockStep.error.resourceNotFound=Resource {0} does not exist. Use createResource: true to create it. +UpdateLockStep.error.resourceInUse=Resource {0} is currently locked or queued and cannot be deleted. +UpdateLockStep.error.resourceReserved=Resource {0} is currently reserved and cannot be deleted. +UpdateLockStep.error.deleteAndCreateConflict=Cannot specify both deleteResource and createResource. +UpdateLockStep.error.deleteWithOtherOptions=When deleteResource is true, other modification options cannot be specified. +UpdateLockStep.error.setLabelsConflict=Cannot use setLabels together with addLabels or removeLabels. +resource.editor.property.name.placeholder=Name +resource.editor.property.value.placeholder=Value +resource.editor.property.remove.label=Remove property +resource.editor.loading=loading... +resource.editor.label.prefix=label: {0} +resource.editor.selectAll=Select all +resource.editor.selectOne=Select {0} +resource.editor.pageSizeAll=All +resource.editor.colvis.all=All +resource.editor.colvis.none=None +resource.editor.labelExpressionFilter.error=Failed to apply label expression filter: {0} +resource.editor.queue.loading=Loading... +resource.editor.table.column.index=# +resource.editor.table.column.resource=Resource +resource.editor.table.column.status=Status +resource.editor.table.column.heldBy=Held By +resource.editor.table.column.reason=Reason +resource.editor.table.column.since=Since +resource.editor.table.column.labels=Labels +resource.editor.table.column.properties=Properties +resource.editor.table.column.actions=Actions +resource.editor.labels.table.column.labels=Labels +resource.editor.labels.table.column.assigned=Assigned Resources +resource.editor.labels.table.column.free=Available +resource.editor.labels.table.column.percentage=Availability % +resource.editor.queue.table.column.position=Position +resource.editor.queue.table.column.action=Reorder +resource.editor.queue.table.column.type=Type +resource.editor.queue.table.column.request=Resource(s) +resource.editor.queue.table.column.reason=Reason +resource.editor.queue.table.column.requestedBy=Requested By +resource.editor.queue.table.column.requestedAt=Queued At +resource.editor.queue.table.column.priority=Priority +resource.editor.queue.table.column.queueId=Queue ID diff --git a/src/main/resources/org/jenkins/plugins/lockableresources/RemoteConnection/config.jelly b/src/main/resources/org/jenkins/plugins/lockableresources/RemoteConnection/config.jelly new file mode 100644 index 000000000..95cdd6392 --- /dev/null +++ b/src/main/resources/org/jenkins/plugins/lockableresources/RemoteConnection/config.jelly @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/src/main/resources/org/jenkins/plugins/lockableresources/RemoteConnection/config.properties b/src/main/resources/org/jenkins/plugins/lockableresources/RemoteConnection/config.properties new file mode 100644 index 000000000..707544670 --- /dev/null +++ b/src/main/resources/org/jenkins/plugins/lockableresources/RemoteConnection/config.properties @@ -0,0 +1,3 @@ +remote.connection.serverId.title=Server ID +remote.connection.url.title=Remote Jenkins URL +remote.connection.credentialsId.title=Credentials ID (optional) diff --git a/src/main/resources/org/jenkins/plugins/lockableresources/RemoteConnection/help-credentialsId.html b/src/main/resources/org/jenkins/plugins/lockableresources/RemoteConnection/help-credentialsId.html new file mode 100644 index 000000000..c0875e3e0 --- /dev/null +++ b/src/main/resources/org/jenkins/plugins/lockableresources/RemoteConnection/help-credentialsId.html @@ -0,0 +1,11 @@ +
+ Jenkins Credentials ID used for remote API authentication. +
+ Expected type: Username with password (API token in password field). +
+ Recommended: use API token, not the account password. +
+ Using username + account password may fail with HTTP 403 due to CSRF/crumb handling on the remote server. +
+ Optional in current implementation. +
diff --git a/src/main/resources/org/jenkins/plugins/lockableresources/RemoteConnection/help-serverId.html b/src/main/resources/org/jenkins/plugins/lockableresources/RemoteConnection/help-serverId.html new file mode 100644 index 000000000..2ae7b2d5e --- /dev/null +++ b/src/main/resources/org/jenkins/plugins/lockableresources/RemoteConnection/help-serverId.html @@ -0,0 +1,5 @@ +
+ Client-side alias used by Pipeline as serverId. +
+ Example: lock(resource: 'board-a1', serverId: 'b') +
diff --git a/src/main/resources/org/jenkins/plugins/lockableresources/RemoteConnection/help-url.html b/src/main/resources/org/jenkins/plugins/lockableresources/RemoteConnection/help-url.html new file mode 100644 index 000000000..00b801ea5 --- /dev/null +++ b/src/main/resources/org/jenkins/plugins/lockableresources/RemoteConnection/help-url.html @@ -0,0 +1,5 @@ +
+ Base URL of the remote Jenkins. +
+ Example: https://jenkins-b.example.com/jenkins +
diff --git a/src/main/resources/org/jenkins/plugins/lockableresources/RequiredResourcesProperty/config.jelly b/src/main/resources/org/jenkins/plugins/lockableresources/RequiredResourcesProperty/config.jelly index dbffcae59..b78a514b1 100644 --- a/src/main/resources/org/jenkins/plugins/lockableresources/RequiredResourcesProperty/config.jelly +++ b/src/main/resources/org/jenkins/plugins/lockableresources/RequiredResourcesProperty/config.jelly @@ -10,24 +10,30 @@ * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * --> - - - - - - - - - - - - - - - - - + xmlns:l="/lib/layout" xmlns:t="/lib/hudson" xmlns:f="/lib/form"> + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/org/jenkins/plugins/lockableresources/RequiredResourcesProperty/config.properties b/src/main/resources/org/jenkins/plugins/lockableresources/RequiredResourcesProperty/config.properties new file mode 100644 index 000000000..a7a527456 --- /dev/null +++ b/src/main/resources/org/jenkins/plugins/lockableresources/RequiredResourcesProperty/config.properties @@ -0,0 +1,30 @@ +# The MIT License +# +# Copyright 2022 Martin Pokorny. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +optionalBlock.title=This build requires lockable resources +entry.resourceNames.title=Resources +entry.labelName.title=Label +optionalProperty.resourceMatchScript.title=Groovy Expression +entry.resourceNamesVar.title=Reserved resources variable name +entry.resourceNumber.title=Number of resources to request +entry.lockTimeout.title=Lock wait timeout +entry.lockTimeoutUnit.title=Timeout unit \ No newline at end of file diff --git a/src/main/resources/org/jenkins/plugins/lockableresources/RequiredResourcesProperty/config_cs.properties b/src/main/resources/org/jenkins/plugins/lockableresources/RequiredResourcesProperty/config_cs.properties new file mode 100644 index 000000000..2d2fec0b0 --- /dev/null +++ b/src/main/resources/org/jenkins/plugins/lockableresources/RequiredResourcesProperty/config_cs.properties @@ -0,0 +1,28 @@ +# The MIT License +# +# Copyright 2022 Martin Pokorny. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +optionalBlock.title=Toto sestaven\u00ed vy\u017eaduje uzamykateln\u00e9 zdroje +entry.resourceNames.title=Zdroje +entry.labelName.title=Popisek +optionalProperty.resourceMatchScript.title=Groovy k\u00f3d +entry.resourceNamesVar.title=N\u00e1zev prom\u011bnn\u00e9 pro rezervovan\u00e9 zdroje +entry.resourceNumber.title=Po\u010det zdroj\u016f na vy\u017e\u00e1d\u00e1n\u00ed \ No newline at end of file diff --git a/src/main/resources/org/jenkins/plugins/lockableresources/RequiredResourcesProperty/config_de.properties b/src/main/resources/org/jenkins/plugins/lockableresources/RequiredResourcesProperty/config_de.properties new file mode 100644 index 000000000..b85e27d8f --- /dev/null +++ b/src/main/resources/org/jenkins/plugins/lockableresources/RequiredResourcesProperty/config_de.properties @@ -0,0 +1,28 @@ +# The MIT License +# +# Copyright 2022 Martin Pokorny. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +optionalBlock.title=Dieses Build erfordert sperrbare Ressourcen +entry.resourceNames.title=Ressourcen +entry.labelName.title=Label +optionalProperty.resourceMatchScript.title=Groovy Expression +entry.resourceNamesVar.title=Variablenname mit reservierten Ressourcen +entry.resourceNumber.title=Anzahl der anzufordernden Ressourcen \ No newline at end of file diff --git a/src/main/resources/org/jenkins/plugins/lockableresources/RequiredResourcesProperty/config_fr.properties b/src/main/resources/org/jenkins/plugins/lockableresources/RequiredResourcesProperty/config_fr.properties new file mode 100644 index 000000000..f2d69e716 --- /dev/null +++ b/src/main/resources/org/jenkins/plugins/lockableresources/RequiredResourcesProperty/config_fr.properties @@ -0,0 +1,28 @@ +# The MIT License +# +# Copyright 2022 Martin Pokorny. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +optionalBlock.title=Ce job n\u00e9cessite des ressources verrouillables +entry.resourceNames.title=Ressources +entry.labelName.title=Libell\u00e9 +optionalProperty.resourceMatchScript.title=Expression Groovy +entry.resourceNamesVar.title=Nom de la variable des ressources r\u00e9serv\u00e9es +entry.resourceNumber.title=Nombre de ressources \u00e0 demander \ No newline at end of file diff --git a/src/main/resources/org/jenkins/plugins/lockableresources/RequiredResourcesProperty/config_sk.properties b/src/main/resources/org/jenkins/plugins/lockableresources/RequiredResourcesProperty/config_sk.properties new file mode 100644 index 000000000..1b82c8fd0 --- /dev/null +++ b/src/main/resources/org/jenkins/plugins/lockableresources/RequiredResourcesProperty/config_sk.properties @@ -0,0 +1,28 @@ +# The MIT License +# +# Copyright 2022 Martin Pokorny. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +optionalBlock.title=Toto zostavenie vy\u017eaduje uzamykate\u013en\u00e9 zdroje +entry.resourceNames.title=Zdroje +entry.labelName.title=\u0160t\u00edtok +optionalProperty.resourceMatchScript.title=Groovy k\u00f3d +entry.resourceNamesVar.title=N\u00e1zov pramennej obsahuj\u00facej rezervovan\u00e9 zdroje +entry.resourceNumber.title=Po\u010det zdrojov na vy\u017eiadanie \ No newline at end of file diff --git a/src/main/resources/org/jenkins/plugins/lockableresources/RequiredResourcesProperty/help-labelName.html b/src/main/resources/org/jenkins/plugins/lockableresources/RequiredResourcesProperty/help-labelName.html index ceb651ff1..50750ccc0 100644 --- a/src/main/resources/org/jenkins/plugins/lockableresources/RequiredResourcesProperty/help-labelName.html +++ b/src/main/resources/org/jenkins/plugins/lockableresources/RequiredResourcesProperty/help-labelName.html @@ -1,8 +1,8 @@
-

-If you have created a pool of resources, i.e. a label, you can take it into use -here. The build will select the resource(s) from the pool that includes all -resources sharing the given label. -Only one of Label, Groovy Expression or Resources fields may be specified. -

+

+ If you have created a pool of resources, i.e. a label, you can take it into use + here. The build will select the resource(s) from the pool that includes all + resources sharing the given label. + Only one of Label, Groovy Expression or Resources fields may be specified. +

diff --git a/src/main/resources/org/jenkins/plugins/lockableresources/RequiredResourcesProperty/help-lockTimeout.html b/src/main/resources/org/jenkins/plugins/lockableresources/RequiredResourcesProperty/help-lockTimeout.html new file mode 100644 index 000000000..f6dbad972 --- /dev/null +++ b/src/main/resources/org/jenkins/plugins/lockableresources/RequiredResourcesProperty/help-lockTimeout.html @@ -0,0 +1,9 @@ +
+

+ Maximum time (in the specified time unit) to wait for a resource to become available. +

+

+ If the resource is not acquired within this time, the build will be cancelled from the queue. + Set to 0 (the default) to wait indefinitely. +

+
diff --git a/src/main/resources/org/jenkins/plugins/lockableresources/RequiredResourcesProperty/help-lockTimeoutUnit.html b/src/main/resources/org/jenkins/plugins/lockableresources/RequiredResourcesProperty/help-lockTimeoutUnit.html new file mode 100644 index 000000000..b8fbddaba --- /dev/null +++ b/src/main/resources/org/jenkins/plugins/lockableresources/RequiredResourcesProperty/help-lockTimeoutUnit.html @@ -0,0 +1,8 @@ +
+

+ The time unit for the lock wait timeout. +

+

+ Allowed values: SECONDS, MINUTES, HOURS. Default is MINUTES. +

+
diff --git a/src/main/resources/org/jenkins/plugins/lockableresources/RequiredResourcesProperty/help-resourceMatchScript.html b/src/main/resources/org/jenkins/plugins/lockableresources/RequiredResourcesProperty/help-resourceMatchScript.html index b60dee9a1..38918d027 100644 --- a/src/main/resources/org/jenkins/plugins/lockableresources/RequiredResourcesProperty/help-resourceMatchScript.html +++ b/src/main/resources/org/jenkins/plugins/lockableresources/RequiredResourcesProperty/help-resourceMatchScript.html @@ -1,28 +1,28 @@
-

- You can specify a groovy expression to be evaluated each time a resource is checked - to be appropriate for a build. The expression must result into a boolean value. The - following variables are available, in addition to optional arguments of the currently - evaluated build: -

-
-
resourceName
-
as per resource configuration
-
resourceDescription
-
as per resource configuration
-
resourceLabels
-
java.util.List<String> of labels as per resource configuration
-
-

- For matrix jobs, axis names and axis values can be referenced as well. Examples: -

-
    -
  • resourceLabels.contains("hardcoded")
  • -
  • resourceLabels.contains(axisName)
  • -
  • resourceName == axisName
  • -
+

+ You can specify a groovy expression to be evaluated each time a resource is checked + to be appropriate for a build. The expression must result into a boolean value. The + following variables are available, in addition to optional arguments of the currently + evaluated build: +

+
+
resourceName
+
as per resource configuration
+
resourceDescription
+
as per resource configuration
+
resourceLabels
+
java.util.List<String> of labels as per resource configuration
+
+

+ For matrix jobs, axis names and axis values can be referenced as well. Examples: +

+
    +
  • resourceLabels.contains("hardcoded")
  • +
  • resourceLabels.contains(axisName)
  • +
  • resourceName == axisName
  • +
-

- The script's contents need to pass approval by the Script Security Plugin. -

-
\ No newline at end of file +

+ The script's contents need to pass approval by the Script Security Plugin. +

+ diff --git a/src/main/resources/org/jenkins/plugins/lockableresources/RequiredResourcesProperty/help-resourceNames.html b/src/main/resources/org/jenkins/plugins/lockableresources/RequiredResourcesProperty/help-resourceNames.html index ae57f1d5f..33f04268e 100644 --- a/src/main/resources/org/jenkins/plugins/lockableresources/RequiredResourcesProperty/help-resourceNames.html +++ b/src/main/resources/org/jenkins/plugins/lockableresources/RequiredResourcesProperty/help-resourceNames.html @@ -1,8 +1,8 @@
-

-When a build is scheduled, it will attempt to lock the specified resources. If -some (or all) the resources are already locked by another build, the build will -be queued until they are released. It is possible to specify an amount for -requested resources below. -

+

+ When a build is scheduled, it will attempt to lock the specified resources. If + some (or all) the resources are already locked by another build, the build will + be queued until they are released. It is possible to specify an amount for + requested resources below. +

diff --git a/src/main/resources/org/jenkins/plugins/lockableresources/RequiredResourcesProperty/help-resourceNamesVar.html b/src/main/resources/org/jenkins/plugins/lockableresources/RequiredResourcesProperty/help-resourceNamesVar.html index e85e61ee5..290c934fb 100644 --- a/src/main/resources/org/jenkins/plugins/lockableresources/RequiredResourcesProperty/help-resourceNamesVar.html +++ b/src/main/resources/org/jenkins/plugins/lockableresources/RequiredResourcesProperty/help-resourceNamesVar.html @@ -1,6 +1,6 @@
-

-Name for the Jenkins variable to store the reserved resources in. Leave empty -to disable. -

+

+ Name for the Jenkins variable to store the reserved resources in. Leave empty + to disable. +

diff --git a/src/main/resources/org/jenkins/plugins/lockableresources/RequiredResourcesProperty/help-resourceNumber.html b/src/main/resources/org/jenkins/plugins/lockableresources/RequiredResourcesProperty/help-resourceNumber.html index 17fbfa8b5..43cb881b5 100644 --- a/src/main/resources/org/jenkins/plugins/lockableresources/RequiredResourcesProperty/help-resourceNumber.html +++ b/src/main/resources/org/jenkins/plugins/lockableresources/RequiredResourcesProperty/help-resourceNumber.html @@ -1,8 +1,8 @@
-

-Number of resources to request, empty value or 0 means all. -
-This is useful, if you have a pool of similar resources, from which you want -one or more to be reserved. -

+

+ Number of resources to request, empty value or 0 means all. +
+ This is useful, if you have a pool of similar resources, from which you want + one or more to be reserved. +

diff --git a/src/main/resources/org/jenkins/plugins/lockableresources/UpdateLockStep/config.jelly b/src/main/resources/org/jenkins/plugins/lockableresources/UpdateLockStep/config.jelly new file mode 100644 index 000000000..1e2289fdf --- /dev/null +++ b/src/main/resources/org/jenkins/plugins/lockableresources/UpdateLockStep/config.jelly @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/org/jenkins/plugins/lockableresources/UpdateLockStep/config.properties b/src/main/resources/org/jenkins/plugins/lockableresources/UpdateLockStep/config.properties new file mode 100644 index 000000000..373ef8f85 --- /dev/null +++ b/src/main/resources/org/jenkins/plugins/lockableresources/UpdateLockStep/config.properties @@ -0,0 +1,7 @@ +entry.resource.title=Resource +entry.createResource.title=Create resource if it does not exist +entry.deleteResource.title=Delete resource +entry.setLabels.title=Set labels +entry.addLabels.title=Add labels +entry.removeLabels.title=Remove labels +entry.setNote.title=Set note diff --git a/src/main/resources/org/jenkins/plugins/lockableresources/UpdateLockStep/help-addLabels.html b/src/main/resources/org/jenkins/plugins/lockableresources/UpdateLockStep/help-addLabels.html new file mode 100644 index 000000000..aef6735a5 --- /dev/null +++ b/src/main/resources/org/jenkins/plugins/lockableresources/UpdateLockStep/help-addLabels.html @@ -0,0 +1,13 @@ +
+

+ Adds labels to the resource without removing existing labels. + Multiple labels should be separated by whitespace. + Labels that already exist on the resource will not be duplicated. +

+

+ Cannot be used together with setLabels. +

+

+ Example: addLabels: 'newlabel1 newlabel2' +

+
diff --git a/src/main/resources/org/jenkins/plugins/lockableresources/UpdateLockStep/help-createResource.html b/src/main/resources/org/jenkins/plugins/lockableresources/UpdateLockStep/help-createResource.html new file mode 100644 index 000000000..53d3032ed --- /dev/null +++ b/src/main/resources/org/jenkins/plugins/lockableresources/UpdateLockStep/help-createResource.html @@ -0,0 +1,9 @@ +
+

+ When set to true, creates the resource if it does not already exist. + The newly created resource will be persistent (not ephemeral). +

+

+ Cannot be used together with deleteResource. +

+
diff --git a/src/main/resources/org/jenkins/plugins/lockableresources/UpdateLockStep/help-deleteResource.html b/src/main/resources/org/jenkins/plugins/lockableresources/UpdateLockStep/help-deleteResource.html new file mode 100644 index 000000000..9a41ca269 --- /dev/null +++ b/src/main/resources/org/jenkins/plugins/lockableresources/UpdateLockStep/help-deleteResource.html @@ -0,0 +1,9 @@ +
+

+ When set to true, deletes the specified resource. + The resource must not be locked, queued, or reserved. +

+

+ Cannot be used together with createResource or any label/note modification options. +

+
diff --git a/src/main/resources/org/jenkins/plugins/lockableresources/UpdateLockStep/help-removeLabels.html b/src/main/resources/org/jenkins/plugins/lockableresources/UpdateLockStep/help-removeLabels.html new file mode 100644 index 000000000..8b39973c1 --- /dev/null +++ b/src/main/resources/org/jenkins/plugins/lockableresources/UpdateLockStep/help-removeLabels.html @@ -0,0 +1,13 @@ +
+

+ Removes the specified labels from the resource. + Multiple labels should be separated by whitespace. + Labels that do not exist on the resource will be ignored. +

+

+ Cannot be used together with setLabels. +

+

+ Example: removeLabels: 'oldlabel1 oldlabel2' +

+
diff --git a/src/main/resources/org/jenkins/plugins/lockableresources/UpdateLockStep/help-resource.html b/src/main/resources/org/jenkins/plugins/lockableresources/UpdateLockStep/help-resource.html new file mode 100644 index 000000000..fd4c7e967 --- /dev/null +++ b/src/main/resources/org/jenkins/plugins/lockableresources/UpdateLockStep/help-resource.html @@ -0,0 +1,8 @@ +
+

+ The name of the resource to update. This parameter is required. +

+

+ The resource must already exist, unless createResource is set to true. +

+
diff --git a/src/main/resources/org/jenkins/plugins/lockableresources/UpdateLockStep/help-setLabels.html b/src/main/resources/org/jenkins/plugins/lockableresources/UpdateLockStep/help-setLabels.html new file mode 100644 index 000000000..7d01ad646 --- /dev/null +++ b/src/main/resources/org/jenkins/plugins/lockableresources/UpdateLockStep/help-setLabels.html @@ -0,0 +1,12 @@ +
+

+ Sets the labels of the resource, replacing any existing labels. + Multiple labels should be separated by whitespace. +

+

+ Cannot be used together with addLabels or removeLabels. +

+

+ Example: setLabels: 'label1 label2 label3' +

+
diff --git a/src/main/resources/org/jenkins/plugins/lockableresources/UpdateLockStep/help-setNote.html b/src/main/resources/org/jenkins/plugins/lockableresources/UpdateLockStep/help-setNote.html new file mode 100644 index 000000000..978acefdf --- /dev/null +++ b/src/main/resources/org/jenkins/plugins/lockableresources/UpdateLockStep/help-setNote.html @@ -0,0 +1,9 @@ +
+

+ Sets the note field of the resource, replacing any existing note. + This can be used to store additional information or comments about the resource. +

+

+ Example: setNote: 'Updated by build #123' +

+
diff --git a/src/main/resources/org/jenkins/plugins/lockableresources/actions/LockableResourcesRootAction/_api.jelly b/src/main/resources/org/jenkins/plugins/lockableresources/actions/LockableResourcesRootAction/_api.jelly new file mode 100644 index 000000000..b0321f03a --- /dev/null +++ b/src/main/resources/org/jenkins/plugins/lockableresources/actions/LockableResourcesRootAction/_api.jelly @@ -0,0 +1,14 @@ + + +
+ This /api adds remote API access to read current resources. +
diff --git a/src/main/resources/org/jenkins/plugins/lockableresources/actions/LockableResourcesRootAction/_content.jelly b/src/main/resources/org/jenkins/plugins/lockableresources/actions/LockableResourcesRootAction/_content.jelly new file mode 100644 index 000000000..adc0b9562 --- /dev/null +++ b/src/main/resources/org/jenkins/plugins/lockableresources/actions/LockableResourcesRootAction/_content.jelly @@ -0,0 +1,380 @@ + + + + + + + + + + + + + + + + + + + +