T3MP3ST v1 — hardened launch (AGPL, verify-claims 24/24, leak-scrubbed)
This commit is contained in:
1 parent
c7a356bde0
commit
6c57e09023
242 files changed
+1761
-51038
No files matched your search
@@ -1 +1 @@
|
||||
{"id": "GHSA-36hh-x5p5-jgc8", "cve": "CVE-2026-44974", "repo": "hapijs/content", "cwes": ["CWE-436"], "vuln_files": ["lib/index.js"], "duration_sec": 143.2, "error": null, "strict": false, "lenient": false, "findings_scored": [{"fileMatch": true, "lineProx": false, "cweMatch": false, "hit": false, "title": "ReDoS (quadratic backtracking) in Content-Disposition parameter parsing", "file": "lib/index.js", "line": 88, "cwe": "CWE-1333"}], "n_findings": 1, "findings": [{"title": "ReDoS (quadratic backtracking) in Content-Disposition parameter parsing", "file": "lib/index.js", "line": 88, "cwe": "CWE-1333", "vuln_class": "dos", "severity": "high", "description": "exports.disposition() validates the header against contentDispositionRegex (line 96), extracts the parameter substring (match[1]), and then runs the global regex internals.contentDispositionParamRegex (line 88) over it via String.replace at line 107. That regex starts with the greedy group ([^\\=\\*\\s]+) which must then be followed by \\s*\\=. When attacker input contains a long run of non-'=', non-'*', non-whitespace characters that never contains an '=' (e.g. 'form-data; aaaaa...'), the greedy group matches the whole run, fails to find '=', and because the regex is global, String.replace re-attempts the match starting at every subsequent position. Each attempt does O(n) work, yielding O(n^2) total time with no upstream length limit in this module. Measured: 20k chars = 0.3s, 40k = 1.3s, 80k = 5.3s, 100k = 8.2s, 200k = 32s. This synchronously blocks the single-threaded Node.js event loop. The header value is fully attacker-controlled and reaches the sink without any length/complexity sanitizer (the only pre-checks are __proto__ guards and the form-data prefix match, neither of which limits input size).", "poc": "Send a multipart request whose part Content-Disposition header (or call Content.disposition() directly) is: 'form-data; ' followed by ~100000 'a' characters with no '=' sign. Example: Content.disposition('form-data; ' + 'a'.repeat(100000)) blocks the event loop for ~8 seconds; scaling the length increases the stall quadratically, enabling a single small-ish request to cause sustained denial of service.", "confidence": 0.9}], "transcript": ""}
|
||||
{"id": "GHSA-36hh-x5p5-jgc8", "cve": "CVE-2026-44974", "repo": "hapijs/content", "cwes": ["CWE-436"], "vuln_files": ["lib/index.js"], "duration_sec": 112.6, "error": null, "strict": true, "lenient": true, "findings_scored": [{"fileMatch": true, "lineProx": true, "cweMatch": false, "hit": true, "title": "Quadratic ReDoS in Content-Disposition parameter parsing", "file": "lib/index.js", "line": 107, "cwe": "CWE-1333"}], "n_findings": 1, "findings": [{"title": "Quadratic ReDoS in Content-Disposition parameter parsing", "file": "lib/index.js", "line": 107, "cwe": "CWE-1333", "vuln_class": "dos", "severity": "high", "description": "exports.disposition(header) parses the attacker-controlled Content-Disposition header. After the outer contentDispositionRegex captures the full parameter tail into match[1] (parameters), the code calls parameters.replace(internals.contentDispositionParamRegex, ...). The param regex begins with the group ([^\\=\\*\\s]+), which greedily matches any run of characters that are not '=', '*' or whitespace and must then be followed by '\\='. When the parameter string contains a long run of such characters with no '=', String.prototype.replace re-attempts the match at every start offset, and at each offset the leading [^=*\\s]+ consumes to the end and backtracks looking for '=' that never appears. This yields O(n^2) time in the length of the header value. No length validation or sanitizer sits between the source (raw header) and this sink. The regex is also module-global (/g) but the dominant cost is the per-offset backtracking. Measured: ~665ms for 40KB, ~2.6s for 80KB, ~10.5s for 160KB (ratio ~4x per 2x input => quadratic). This is reachable unauthenticated on any hapi endpoint accepting multipart uploads.", "poc": "Send an HTTP multipart request whose part contains a header: Content-Disposition: form-data; name=\"x\"; aaaaaaaa...aaaa (append ~150,000+ 'a' characters, i.e. a long run containing no '=' sign). Parsing this single header via content.disposition() blocks the Node.js event loop for ~10 seconds; a few such concurrent requests hang the server. Minimal reproducer: require('@hapi/content').disposition('form-data; name=\"x\"; ' + 'a'.repeat(160000));", "confidence": 0.88}], "transcript": ""}
|
||||
@@ -1 +1 @@
|
||||
{"id": "GHSA-3jmg-p96m-m328", "cve": "CVE-2026-46410", "repo": "gtsteffaniak/filebrowser", "cwes": ["CWE-200"], "vuln_files": ["backend/common/settings/settings.go"], "duration_sec": 81.8, "error": null, "strict": false, "lenient": false, "findings_scored": [], "n_findings": 0, "findings": [], "transcript": ""}
|
||||
{"id": "GHSA-3jmg-p96m-m328", "cve": "CVE-2026-46410", "repo": "gtsteffaniak/filebrowser", "cwes": ["CWE-200"], "vuln_files": ["backend/common/settings/settings.go"], "duration_sec": 80.3, "error": null, "strict": false, "lenient": false, "findings_scored": [{"fileMatch": false, "lineProx": false, "cweMatch": false, "hit": false, "title": "JWT signing key never randomly generated when unset \u2014 tokens signed/verified with empty HMAC secret", "file": "backend/common/settings/config.go", "line": 433, "cwe": "CWE-321"}, {"fileMatch": false, "lineProx": false, "cweMatch": false, "hit": false, "title": "LDAP userFilter used as a printf format template for the login username (LDAP search filter injection)", "file": "backend/common/settings/auth.go", "line": 150, "cwe": "CWE-90"}], "n_findings": 2, "findings": [{"title": "JWT signing key never randomly generated when unset \u2014 tokens signed/verified with empty HMAC secret", "file": "backend/common/settings/config.go", "line": 433, "cwe": "CWE-321", "vuln_class": "auth-bypass", "severity": "critical", "description": "The Auth.Key field is documented (auth.go:50) as 'the key used to sign the JWT tokens. If not set, a random key will be generated.' However, the runtime initialization path Initialize() -> loadConfigWithDefaults() -> setupAuth() NEVER generates a random key. SetDefaults() leaves Auth.Key = \"\" (empty), loadEnvConfig() only sets it if FILEBROWSER_JWT_TOKEN_SECRET is provided (config.go:940), and setupAuth() (config.go:433) does nothing about the key. The GenerateKey() helper (settings.go:43) that produces a 64-byte crypto/rand secret exists but is never called anywhere in the module. The only guard, storage.go:36 (ErrEmptyKey), is in the persistence-Save path, not the Initialize runtime-config path used to derive the in-memory Config.Auth.Key that signs and verifies session JWTs. Consequently, any default deployment that does not explicitly set auth.key in YAML or the FILEBROWSER_JWT_TOKEN_SECRET env var runs with an empty HMAC signing key. Since the validConfig.yaml example ships with password auth enabled and no auth.key, this is the default state. An attacker who knows the empty/blank key (a fixed, guessable value) can forge arbitrary session JWTs \u2014 e.g. mint a token claiming the admin username \u2014 resulting in full authentication bypass / privilege escalation.", "poc": "Deploy filebrowser with the shipped default/validConfig.yaml (password auth enabled, no auth.key set, no FILEBROWSER_JWT_TOKEN_SECRET env var). Config.Auth.Key is the empty string. Forge a session JWT signed with HMAC-SHA256 using an empty key and a body claiming an admin user, e.g. header {\"alg\":\"HS256\",\"typ\":\"JWT\"}, payload {\"user\":\"admin\",\"exp\":<future>} signed with key=\"\". Present it as the session cookie/bearer token to gain authenticated admin access. Because the verifier uses the same empty key, the forged token validates.", "confidence": 0.72}, {"title": "LDAP userFilter used as a printf format template for the login username (LDAP search filter injection)", "file": "backend/common/settings/auth.go", "line": 150, "cwe": "CWE-90", "vuln_class": "injection", "severity": "medium", "description": "UserFilter defaults to '(&(cn=%s)(objectClass=user))' and is designed to be fmt.Sprintf'd with the attacker-supplied login username at authentication time. There is no ldap.EscapeFilter applied to the username before substitution, allowing LDAP filter metacharacters (e.g. '*', ')(', etc.) to alter the search filter semantics. Note: the actual Sprintf/search occurs in the login handler outside this module scope, so only the vulnerable template definition and lack of escaping are visible here.", "poc": "Log in via LDAP with username '*' or 'x)(uid=*))(|(uid=*' to broaden/alter the LDAP search filter and match unintended directory entries or bypass the intended cn match.", "confidence": 0.3}], "transcript": ""}
|
||||
@@ -1 +1 @@
|
||||
{"id": "GHSA-fqvv-jvhr-g5jc", "cve": "CVE-2026-42864", "repo": "ManoManoTech/firefighter-incident", "cwes": ["CWE-306", "CWE-918"], "vuln_files": ["src/firefighter/raid/serializers.py", "src/firefighter/raid/views/__init__.py"], "duration_sec": 188.9, "error": null, "strict": false, "lenient": true, "findings_scored": [{"fileMatch": false, "lineProx": false, "cweMatch": true, "hit": false, "title": "Unauthenticated SSRF via attacker-controlled attachment URLs in Jira bot endpoint", "file": "src/firefighter/raid/client.py", "line": 150, "cwe": "CWE-918"}, {"fileMatch": true, "lineProx": false, "cweMatch": true, "hit": true, "title": "Unauthenticated Jira webhook handlers allow forged incident status/priority changes", "file": "src/firefighter/raid/views/__init__.py", "line": 97, "cwe": "CWE-306"}], "n_findings": 2, "findings": [{"title": "Unauthenticated SSRF via attacker-controlled attachment URLs in Jira bot endpoint", "file": "src/firefighter/raid/client.py", "line": 150, "cwe": "CWE-918", "vuln_class": "ssrf", "severity": "high", "description": "The `CreateJiraBotView` API endpoint (urls.py -> views/__init__.py) is exposed with `permission_classes = [permissions.AllowAny]`, so it is reachable by an unauthenticated attacker. Its serializer `LandbotIssueRequestSerializer` accepts an `attachments` field as a plain `CharField` with no URL/scheme/host validation. In `LandbotIssueRequestSerializer.create()` (serializers.py ~lines 247-257) the attachments string is split into a list and passed directly to `jira_client.add_attachments_to_issue(issue_id, attachments)`. The sink `RaidJiraClient.add_attachments_to_issue` (client.py:150) iterates the attacker-supplied list and performs `http_client.get(url)` for each entry with no allow-list, scheme check, or private-IP/metadata-host filtering. An attacker can therefore force the server to issue arbitrary outbound HTTP(S) GET requests to internal-only services or the cloud metadata endpoint, enabling internal port scanning and (on GCP/AWS) retrieval of instance metadata/credentials. The retrieved content is uploaded as a Jira attachment, providing an exfiltration channel for the fetched body.", "poc": "POST /api/<version>/raid/jira_bot HTTP/1.1\nContent-Type: application/json\n\n{\n \"summary\": \"x\",\n \"description\": \"x\",\n \"seller_contract_id\": \"1\",\n \"zoho\": \"\",\n \"platform\": \"FR\",\n \"reporter_email\": \"attacker@example.com\",\n \"incident_category\": \"\",\n \"suggested_team_routing\": \"SBI\",\n \"priority\": 4,\n \"business_impact\": \"Low\",\n \"attachments\": \"http://169.254.169.254/computeMetadata/v1/instance/service-accounts/default/token\"\n}\n\nThe server (issue creation succeeds, then) performs http_client.get('http://169.254.169.254/...') from inside the trusted network and attaches the response body (e.g., the GCP service-account token) to the created Jira issue.", "confidence": 0.85}, {"title": "Unauthenticated Jira webhook handlers allow forged incident status/priority changes", "file": "src/firefighter/raid/views/__init__.py", "line": 97, "cwe": "CWE-306", "vuln_class": "auth-bypass", "severity": "medium", "description": "`JiraUpdateAlertView` and `JiraCommentAlertView` set `authentication_classes = []` and `permission_classes = [permissions.AllowAny]` (the maintainers left an `XXX: Work on webhook token` note). An attacker can POST a forged Jira `jira:issue_updated` webhook referencing any JiraTicket key. Through `JiraWebhookUpdateSerializer._handle_status_update`/`_handle_priority_update` this lets an unauthenticated remote attacker drive a real incident to CLOSED status or change its priority (calling `incident.create_incident_update`), and spam Slack channels with attacker-controlled author/comment text. Exploitation requires knowing/guessing a JiraTicket key, but keys are low-entropy sequential (PROJECT-NNN).", "poc": "POST /api/<version>/raid/jira_update with body {\"webhookEvent\":\"jira:issue_updated\",\"user\":{\"displayName\":\"x\"},\"issue\":{\"id\":1,\"key\":\"SBI-123\"},\"changelog\":{\"items\":[{\"field\":\"status\",\"fromString\":\"Reporter validation\",\"toString\":\"Closed\"}]}}", "confidence": 0.55}], "transcript": ""}
|
||||
{"id": "GHSA-fqvv-jvhr-g5jc", "cve": "CVE-2026-42864", "repo": "ManoManoTech/firefighter-incident", "cwes": ["CWE-306", "CWE-918"], "vuln_files": ["src/firefighter/raid/serializers.py", "src/firefighter/raid/views/__init__.py"], "duration_sec": 175.1, "error": null, "strict": false, "lenient": false, "findings_scored": [{"fileMatch": false, "lineProx": false, "cweMatch": true, "hit": false, "title": "Unauthenticated SSRF via attachment URLs in Jira bot issue creation", "file": "src/firefighter/raid/client.py", "line": 150, "cwe": "CWE-918"}], "n_findings": 1, "findings": [{"title": "Unauthenticated SSRF via attachment URLs in Jira bot issue creation", "file": "src/firefighter/raid/client.py", "line": 150, "cwe": "CWE-918", "vuln_class": "ssrf", "severity": "high", "description": "The endpoint POST /api/v2/raid/jira_bot is served by CreateJiraBotView, which sets permission_classes = [permissions.AllowAny] and performs no authentication in code, making it attacker-reachable without credentials. Its serializer (LandbotIssueRequestSerializer) declares `attachments` as a plain serializers.CharField with no URL/scheme/host validation. In LandbotIssueRequestSerializer.create() (serializers.py:265) the raw attachment string is only stripped of brackets/quotes and split on ', ', then passed verbatim to jira_client.add_attachments_to_issue(issue_id, attachments). In RaidJiraClient.add_attachments_to_issue (client.py:135-150) each element is fetched via http_client.get(url) with no allowlist, scheme check, or IP filtering. An unauthenticated attacker therefore fully controls the server-side request target, enabling requests to internal-only services, cloud metadata endpoints (e.g. 169.254.169.254), and arbitrary internal hosts; the fetched content is even attached to the created Jira issue, allowing exfiltration of internal responses. The response.content is read into memory (io.BytesIO), so large/slow targets also enable DoS.", "poc": "POST /api/v2/raid/jira_bot HTTP/1.1\nContent-Type: application/json\n\n{\n \"summary\": \"x\",\n \"description\": \"x\",\n \"seller_contract_id\": \"1\",\n \"zoho\": \"1\",\n \"platform\": \"FR\",\n \"reporter_email\": \"a@b.com\",\n \"incident_category\": \"x\",\n \"suggested_team_routing\": \"SBI\",\n \"priority\": 4,\n \"business_impact\": \"Low\",\n \"environments\": [\"PRD\"],\n \"issue_type\": \"Incident\",\n \"attachments\": \"http://169.254.169.254/latest/meta-data/iam/security-credentials/\"\n}\n\nThe server issues an HTTP GET to the internal metadata endpoint; the response body is attached to the created Jira ticket (image0/), leaking internal data. Any internal URL (http://localhost:6379/, http://internal-admin/) works equally.", "confidence": 0.85}], "transcript": ""}
|
||||
@@ -1 +1 @@
|
||||
{"id": "GHSA-hm2w-vr2p-hq7w", "cve": null, "repo": "theopolis/uefi-firmware-parser", "cwes": ["CWE-787"], "vuln_files": ["uefi_firmware/compression/Tiano/Decompress.c"], "duration_sec": 135, "error": null, "strict": true, "lenient": true, "findings_scored": [{"fileMatch": true, "lineProx": true, "cweMatch": true, "hit": true, "title": "Out-of-bounds write in ReadCLen: attacker-controlled run-length fills mCLen[] past NC with no bounds check", "file": "uefi_firmware/compression/Tiano/Decompress.c", "line": 525, "cwe": "CWE-787"}, {"fileMatch": true, "lineProx": true, "cweMatch": false, "hit": true, "title": "GetInfo reports DstSize purely from attacker-controlled header (size mismatch / undersized output buffer risk)", "file": "uefi_firmware/compression/Tiano/Decompress.c", "line": 717, "cwe": "CWE-130"}], "n_findings": 2, "findings": [{"title": "Out-of-bounds write in ReadCLen: attacker-controlled run-length fills mCLen[] past NC with no bounds check", "file": "uefi_firmware/compression/Tiano/Decompress.c", "line": 525, "cwe": "CWE-787", "vuln_class": "out-of-bounds-write", "severity": "critical", "description": "In ReadCLen(), the symbol count `Number` is read from 9 attacker-controlled bits (GetBits(Sd, CBIT), CBIT=9 => up to 511) and the outer loop runs `while (Index < Number)`. Inside, when the decoded CharC<=2 it performs a run-length zero-fill: for CharC==2 the run length is `GetBits(Sd, CBIT) + 20` (up to 531) and the inner loop `while ((INT16)CharC >= 0) Sd->mCLen[Index++] = 0;` advances Index by up to ~531 in a single outer iteration. There is NO `Index >= NC` bound check anywhere in ReadCLen (NC=510, mCLen is UINT8 mCLen[510]). Because the outer bound is only re-checked between iterations and Index can jump by hundreds at a time, Index can be driven far beyond NC, writing zero bytes well past mCLen[] into the adjacent struct members (mPTLen, mCTable, mPTTable) and past the end of the SCRATCH_DATA scratch allocation. All inputs flow unvalidated from the compressed source buffer (Decompress -> Decode -> DecodeC -> ReadCLen) which is attacker-controlled firmware. Note the maintainer added a guard in MakeTable (Index>=TableSize) and a DataIdx check in Decode, but the analogous Index>=NC guard required in ReadCLen is missing (it exists in upstream/fixed EDK2). The line 531 single-byte write path is also unguarded.", "poc": "Provide a crafted EFI/Tiano compressed blob to EfiDecompress()/TianoDecompress(): 8-byte header CompSize/OrigSize consistent with SrcSize, then a block whose ReadPTLen tables decode the Char&Len set such that ReadCLen reads Number (9 bits) = large value and repeatedly decodes the code that maps to CharC==2, supplying GetBits(9)=large run lengths so the inner `Sd->mCLen[Index++] = 0;` loop advances Index past 510 and keeps overrunning across many outer iterations, corrupting memory beyond mCLen[510] within/after the SCRATCH_DATA buffer.", "confidence": 0.9}, {"title": "GetInfo reports DstSize purely from attacker-controlled header (size mismatch / undersized output buffer risk)", "file": "uefi_firmware/compression/Tiano/Decompress.c", "line": 717, "cwe": "CWE-130", "vuln_class": "info-disclosure", "severity": "low", "description": "GetInfo() derives *DstSize entirely from the 4-byte OrigSize field in the attacker-controlled source header with no sanity bound vs SrcSize. Decompress() later enforces DstSize==OrigSize, so a mismatch is rejected, but a caller that allocates based on this value and then runs the decoder is exposed to the ReadCLen/Decode write primitives above. Secondary to the primary finding.", "poc": "Header bytes 4..7 set to an arbitrary 32-bit OrigSize control the size returned to the caller for buffer allocation.", "confidence": 0.25}], "transcript": ""}
|
||||
{"id": "GHSA-hm2w-vr2p-hq7w", "cve": null, "repo": "theopolis/uefi-firmware-parser", "cwes": ["CWE-787"], "vuln_files": ["uefi_firmware/compression/Tiano/Decompress.c"], "duration_sec": 139.8, "error": null, "strict": true, "lenient": true, "findings_scored": [{"fileMatch": true, "lineProx": true, "cweMatch": true, "hit": true, "title": "Out-of-bounds write in ReadCLen via unbounded code-length run expansion (missing Index >= NC guard)", "file": "uefi_firmware/compression/Tiano/Decompress.c", "line": 525, "cwe": "CWE-787"}, {"fileMatch": true, "lineProx": true, "cweMatch": true, "hit": true, "title": "Out-of-bounds read of destination buffer when DataIdx == mOrigSize (off-by-one in copy bound check)", "file": "uefi_firmware/compression/Tiano/Decompress.c", "line": 662, "cwe": "CWE-125"}], "n_findings": 2, "findings": [{"title": "Out-of-bounds write in ReadCLen via unbounded code-length run expansion (missing Index >= NC guard)", "file": "uefi_firmware/compression/Tiano/Decompress.c", "line": 525, "cwe": "CWE-787", "vuln_class": "buffer-overflow (out-of-bounds write)", "severity": "critical", "description": "ReadCLen() parses the Char&Len code-length table from the attacker-controlled compressed stream. Number = GetBits(Sd, CBIT=9) yields up to 511, while mCLen is only NC = (0xff + MAXMATCH + 2 - THRESHOLD) = 510 bytes. The loop `while (Index < Number)` writes Sd->mCLen[Index++] with NO check that Index < NC (the upstream EDK2 fix adds such a guard; it is absent here). Worse, the run-length branch reads CharC = GetBits(Sd, CBIT) + 20 (up to 531) and then executes `while ((INT16)CharC >= 0) { Sd->mCLen[Index++] = 0; CharC--; }`, so a single symbol can advance Index by up to ~531 with no bound. Index (UINT16) can therefore run far past 510, overflowing mCLen and clobbering the adjacent fields of SCRATCH_DATA (mPTLen[31], then mCTable[4096], mPTTable[256]). All of these values (Number and each CharC) are pulled directly from the compressed bitstream via GetBits with no validation or sanitizer between source and sink. Reachable unauthenticated by simply feeding a crafted Tiano/EFI-compressed section to TianoDecompress()/EfiDecompress()/Decompress(), which the parser invokes on untrusted firmware images.", "poc": "Build an EFI/Tiano compressed blob whose 8-byte header sets CompSize>=8 and any OrigSize (DstSize must equal OrigSize). In the first block: BlockSize (16 bits) nonzero; encode the Extra Set (ReadPTLen NT) as a trivial valid table; then in ReadCLen set Number (9 bits) = 511, and repeatedly emit the CharC==2 symbol whose run count GetBits(9)+20 = 531 zeros. On the first such symbol Index jumps from 0 to 531, writing Sd->mCLen[0..530]=0 \u2014 21 bytes past the 510-byte array \u2014 and subsequent iterations (Index<511) push writes hundreds of bytes further, corrupting mPTLen and mCTable in the SCRATCH_DATA buffer.", "confidence": 0.85}, {"title": "Out-of-bounds read of destination buffer when DataIdx == mOrigSize (off-by-one in copy bound check)", "file": "uefi_firmware/compression/Tiano/Decompress.c", "line": 662, "cwe": "CWE-125", "vuln_class": "info-disclosure (out-of-bounds read)", "severity": "low", "description": "In Decode(), the back-reference source index is validated with `if (DataIdx > Sd->mOrigSize)` using '>' rather than '>='. When DecodeP() yields a value making DataIdx exactly equal to mOrigSize, the guard passes and the copy loop reads Sd->mDstBase[DataIdx] one byte past the end of the destination buffer. Both DataIdx (derived from DecodeP over attacker bits) and the copy are attacker-influenced.", "poc": "Craft compressed data whose pointer decode produces DecodeP(Sd) such that mOutBuf - DecodeP - 1 == mOrigSize, passing the '>' check and reading mDstBase[mOrigSize].", "confidence": 0.3}], "transcript": ""}
|
||||
@@ -1 +1 @@
|
||||
{"id": "GHSA-hx53-77qj-8663", "cve": "CVE-2026-7474", "repo": "hashicorp/nomad", "cwes": ["CWE-22"], "vuln_files": ["client/hostvolumemanager/host_volume_plugin.go", "client/hostvolumemanager/host_volume_plugin_test.go", "client/hostvolumemanager/host_volumes_test.go"], "duration_sec": 95.4, "error": null, "strict": true, "lenient": true, "findings_scored": [{"fileMatch": true, "lineProx": false, "cweMatch": true, "hit": true, "title": "Path traversal in host-volume ID enables arbitrary directory deletion (RemoveAll) and creation on the client host", "file": "client/hostvolumemanager/host_volume_plugin.go", "line": 210, "cwe": "CWE-22"}, {"fileMatch": true, "lineProx": true, "cweMatch": true, "hit": true, "title": "Path traversal in PluginID selects and executes an arbitrary on-disk executable", "file": "client/hostvolumemanager/host_volume_plugin.go", "line": 229, "cwe": "CWE-22"}], "n_findings": 2, "findings": [{"title": "Path traversal in host-volume ID enables arbitrary directory deletion (RemoveAll) and creation on the client host", "file": "client/hostvolumemanager/host_volume_plugin.go", "line": 210, "cwe": "CWE-22", "vuln_class": "path-traversal", "severity": "high", "description": "The built-in 'mkdir' host-volume plugin builds the on-disk target path with path := filepath.Join(p.VolumesDir, req.ID) (lines 105 and 203) and then performs destructive filesystem operations: os.MkdirAll/os.Chmod/os.Chown in Create (lines 133-160) and os.RemoveAll(path) in Delete (line 210). req.ID originates from the ClientHostVolumeDeleteRequest/ClientHostVolumeCreateRequest forwarded verbatim to the client RPC handler (HostVolume.Create/Delete -> HostVolumeManager.Create/Delete -> getPlugin -> HostVolumePluginMkdir). Nowhere in client/hostvolumemanager is req.ID validated to reject '..' or absolute components, and filepath.Join cleans the path so a traversal like '../../../../etc' escapes VolumesDir. Because the Nomad client agent typically runs as root, a request with a traversing ID causes os.RemoveAll to recursively delete an arbitrary host directory (e.g. the volumes dir's parents, /etc, plugin dirs), and Create can chown/chmod arbitrary existing directories. This is a destructive, attacker-influenced filesystem primitive reachable from the dynamic-host-volume RPC path with no sanitizer between source and sink.", "poc": "Issue a dynamic host volume delete (or create) whose volume ID contains traversal, e.g. ClientHostVolumeDeleteRequest{PluginID:\"mkdir\", ID:\"../../../../../../etc/cron.d\", NodeID:<target>} -> client computes path=filepath.Join(VolumesDir, \"../../../../../../etc/cron.d\") and runs os.RemoveAll on it, deleting /etc/cron.d on the host. With Create, ID=\"../../../../some/existing/dir\" plus parameters {mode:777,uid:0} chmods/chowns that dir.", "confidence": 0.7}, {"title": "Path traversal in PluginID selects and executes an arbitrary on-disk executable", "file": "client/hostvolumemanager/host_volume_plugin.go", "line": 229, "cwe": "CWE-22", "vuln_class": "rce", "severity": "high", "description": "getPlugin(req.PluginID) (host_volumes.go) falls through to NewHostVolumePluginExternal(log, hvm.pluginDir, id, ...) where id == req.PluginID. The executable path is built as executable := filepath.Join(pluginDir, filename) (line 229) with no rejection of '..' or absolute path components. filepath.Join cleans the path, so a PluginID like '../../../../bin/sh' resolves to an executable outside the configured plugin directory. The only guard is os.Stat + helper.IsExecutable, so any executable file on the host can be selected and then invoked via exec.CommandContext in runPlugin/Fingerprint/Create/Delete with attacker-controlled environment variables (DHV_PARAMETERS, DHV_VOLUME_ID, etc.). The Nomad client runs this as root, allowing execution of arbitrary host binaries with attacker-influenced environment as part of volume create/delete/fingerprint.", "poc": "Send a dynamic host volume create/delete with PluginID=\"../../../../../../usr/bin/<target-binary>\" so filepath.Join(pluginDir, PluginID) resolves to an arbitrary executable, which Nomad then runs as the client process user (typically root) with attacker-controlled DHV_* env vars.", "confidence": 0.55}], "transcript": ""}
|
||||
{"id": "GHSA-hx53-77qj-8663", "cve": "CVE-2026-7474", "repo": "hashicorp/nomad", "cwes": ["CWE-22"], "vuln_files": ["client/hostvolumemanager/host_volume_plugin.go", "client/hostvolumemanager/host_volume_plugin_test.go", "client/hostvolumemanager/host_volumes_test.go"], "duration_sec": 186.8, "error": null, "strict": true, "lenient": true, "findings_scored": [{"fileMatch": true, "lineProx": true, "cweMatch": true, "hit": true, "title": "Path traversal in external host-volume plugin resolution allows execution of arbitrary host executables", "file": "client/hostvolumemanager/host_volume_plugin.go", "line": 229, "cwe": "CWE-22"}, {"fileMatch": true, "lineProx": false, "cweMatch": true, "hit": true, "title": "Path traversal via volume ID in built-in mkdir plugin enables arbitrary directory creation/deletion", "file": "client/hostvolumemanager/host_volume_plugin.go", "line": 203, "cwe": "CWE-22"}], "n_findings": 2, "findings": [{"title": "Path traversal in external host-volume plugin resolution allows execution of arbitrary host executables", "file": "client/hostvolumemanager/host_volume_plugin.go", "line": 229, "cwe": "CWE-22", "vuln_class": "path-traversal", "severity": "high", "description": "HostVolumeManager.getPlugin() passes the caller-supplied req.PluginID straight to NewHostVolumePluginExternal() as `filename`, which builds the executable path via `filepath.Join(pluginDir, filename)` (line 229) with no filepath.Base()/dot-dot rejection. filepath.Join resolves `..` segments, so a PluginID like `../../../../bin/mount` (or any executable on the host) escapes the configured plugin directory. If the resolved path exists and IsExecutable, HostVolumePluginExternal.runPlugin runs it via exec.CommandContext with attacker-influenced environment variables (DHV_PARAMETERS, DHV_VOLUME_NAME, etc.) as the Nomad client process (commonly root). The data flow is: volume create/delete RPC (cstructs.ClientHostVolumeCreateRequest.PluginID) -> HostVolumeManager.Create/Delete -> getPlugin(req.PluginID) -> NewHostVolumePluginExternal(pluginDir, id) -> filepath.Join -> exec.CommandContext. No sanitizer validates the PluginID against traversal anywhere in the module.", "poc": "Submit a dynamic host volume create request whose plugin_id is a traversal path to a pre-existing executable, e.g. plugin_id = \"../../../../../../bin/sh\" (or any script/binary reachable from the client's plugin dir). getPlugin -> NewHostVolumePluginExternal resolves executable=/bin/sh (outside plugin dir) and Nomad executes it with the attacker-controlled DHV_* environment.", "confidence": 0.8}, {"title": "Path traversal via volume ID in built-in mkdir plugin enables arbitrary directory creation/deletion", "file": "client/hostvolumemanager/host_volume_plugin.go", "line": 203, "cwe": "CWE-22", "vuln_class": "path-traversal", "severity": "high", "description": "HostVolumePluginMkdir.Create (line 105) and Delete (line 203) compute the target path as `filepath.Join(p.VolumesDir, req.ID)` using the caller-supplied req.ID with no validation that it stays under VolumesDir. filepath.Join collapses `..` segments, so a volume ID containing `../` escapes VolumesDir. In Delete this reaches os.RemoveAll(path), giving an arbitrary recursive-delete primitive on the client host; in Create it reaches os.MkdirAll/os.Chmod/os.Chown on an attacker-chosen path. The ID flows unvalidated from the ClientHostVolume RPC args into the plugin.", "poc": "Create/delete a dynamic host volume using the built-in \"mkdir\" plugin with ID = \"../../../../etc/cron.d/evil\" (create) or ID = \"../../../../var/important\" (delete). Delete triggers os.RemoveAll(filepath.Join(VolumesDir, \"../../../../var/important\")) removing arbitrary host paths as the Nomad client user.", "confidence": 0.6}], "transcript": ""}
|
||||
@@ -1 +1 @@
|
||||
{"id": "GHSA-m6xr-fvfg-5g64", "cve": "CVE-2026-46378", "repo": "TomWright/dasel", "cwes": ["CWE-835"], "vuln_files": ["selector/lexer/tokenize.go", "selector/lexer/tokenize_test.go"], "duration_sec": 77.8, "error": null, "strict": false, "lenient": true, "findings_scored": [{"fileMatch": true, "lineProx": false, "cweMatch": false, "hit": false, "title": "Panic (index out of range) when selector ends with whitespace", "file": "selector/lexer/tokenize.go", "line": 60, "cwe": "CWE-125"}, {"fileMatch": true, "lineProx": true, "cweMatch": true, "hit": true, "title": "Infinite loop on unterminated regex literal r/...", "file": "selector/lexer/tokenize.go", "line": 280, "cwe": "CWE-835"}], "n_findings": 2, "findings": [{"title": "Panic (index out of range) when selector ends with whitespace", "file": "selector/lexer/tokenize.go", "line": 60, "cwe": "CWE-125", "vuln_class": "dos", "severity": "high", "description": "In parseCurRune(), after the leading whitespace-skip loop (line 55) advances p.i, the code unconditionally dereferences p.src[p.i] at line 60 (the '//' comment check) and again at line 74 (the main switch) without re-checking p.i < p.srcLen. Next() guards only the entry condition p.i >= len(p.src), so any selector that contains trailing whitespace (or whose remaining suffix is only whitespace after a comment) will cause p.i to reach p.srcLen inside parseCurRune, and the next p.src[p.i] access panics with 'runtime error: index out of range'. Anywhere dasel exposes selector parsing to untrusted input (CLI args, library users, or any service embedding dasel's selector lexer), a one-byte attacker-controlled string such as a single space crashes the process. Tokenize() is the documented entrypoint, so reachability is trivial.", "poc": "lexer.NewTokenizer(\" \").Tokenize() // panics: runtime error: index out of range [1] with length 1. Also reproducible with any selector ending in whitespace, e.g. \"foo \" or \"a.b \".", "confidence": 0.95}, {"title": "Infinite loop on unterminated regex literal r/...", "file": "selector/lexer/tokenize.go", "line": 280, "cwe": "CWE-835", "vuln_class": "dos", "severity": "high", "description": "matchRegexPattern (line 274) recognises an 'r/' prefix and then scans for the closing '/': `for !p.peekRuneEqual(pos, '/') { pos++ }`. peekRuneEqual returns false whenever pos >= p.srcLen, so once pos walks past the end of the input without finding a '/', the condition stays true forever and pos is incremented without bound. There is no srcLen guard inside the loop and no error path for an unterminated regex. Any selector containing `r/` without a matching closing slash causes Tokenize() to hang, consuming a CPU core indefinitely (and eventually causing an integer overflow / huge allocation if it ever exited at p.src[start+2:pos-1]).", "poc": "lexer.NewTokenizer(\"r/abc\").Tokenize() // hangs forever inside matchRegexPattern's scan loop.", "confidence": 0.95}], "transcript": ""}
|
||||
{"id": "GHSA-m6xr-fvfg-5g64", "cve": "CVE-2026-46378", "repo": "TomWright/dasel", "cwes": ["CWE-835"], "vuln_files": ["selector/lexer/tokenize.go", "selector/lexer/tokenize_test.go"], "duration_sec": 195.7, "error": null, "strict": true, "lenient": true, "findings_scored": [{"fileMatch": true, "lineProx": true, "cweMatch": true, "hit": true, "title": "Unbounded loop when lexing an unterminated regex pattern (r/...)", "file": "selector/lexer/tokenize.go", "line": 280, "cwe": "CWE-835"}, {"fileMatch": true, "lineProx": false, "cweMatch": false, "hit": false, "title": "Index-out-of-range panic on all-whitespace / trailing-whitespace selector", "file": "selector/lexer/tokenize.go", "line": 59, "cwe": "CWE-125"}], "n_findings": 2, "findings": [{"title": "Unbounded loop when lexing an unterminated regex pattern (r/...)", "file": "selector/lexer/tokenize.go", "line": 280, "cwe": "CWE-835", "vuln_class": "dos", "description": "matchRegexPattern is invoked whenever a token begins with the two characters 'r/'. After consuming 'r/' (pos += 2), the loop `for !p.peekRuneEqual(pos, '/') { pos++ }` scans for the closing slash with NO upper-bound check on pos. peekRuneEqual returns false for any pos >= srcLen (it never signals end-of-input), so when the input contains 'r/' but no closing '/', the negated condition (!false) stays true forever and pos is incremented without limit. The lexer hangs, pinning a CPU core indefinitely. Because dasel tokenizes attacker-supplied selector strings, any caller that lexes a user-provided selector containing an unterminated regex literal is subject to an unrecoverable denial of service (a hung goroutine, unlike a panic, cannot be caught with recover). Note the subsequent slice p.src[start+2:pos-1] would also panic once pos exceeds len(src), but the loop never exits to reach it.", "poc": "Tokenize a selector that contains an unterminated regex pattern, e.g. lexer.NewTokenizer(\"r/abc\").Tokenize() or any selector like \".foo == r/unterminated\". The Tokenize() call never returns and consumes 100% CPU.", "confidence": 0.9}, {"title": "Index-out-of-range panic on all-whitespace / trailing-whitespace selector", "file": "selector/lexer/tokenize.go", "line": 59, "cwe": "CWE-125", "vuln_class": "dos", "description": "parseCurRune first advances past whitespace with `for p.i < p.srcLen && unicode.IsSpace(...) { p.i++ }`, after which p.i can legitimately equal p.srcLen. The very next statement, the comment check `p.src[p.i] == '/'`, dereferences p.src[p.i] with no `p.i < p.srcLen` guard. Next() only guards the initial call (p.i >= len(p.src)), so a selector consisting of (or ending in) whitespace drives p.i to srcLen and triggers an out-of-range index panic. Combined with the rune-count/byte-index mismatch (srcLen = len([]rune(src)) but indexing uses byte offsets p.src[p.i]), multi-byte UTF-8 selectors can also index past valid positions. Any service tokenizing an untrusted selector can be crashed.", "poc": "lexer.NewTokenizer(\" \").Tokenize() // single space -> panic: runtime error: index out of range [1] with length 1. Also triggered by any selector ending in whitespace, e.g. \".foo \".", "confidence": 0.75}], "transcript": ""}
|
||||
@@ -1 +1 @@
|
||||
{"id": "GHSA-ph9p-34f9-6g65", "cve": "CVE-2026-44705", "repo": "raszi/node-tmp", "cwes": ["CWE-22"], "vuln_files": ["lib/tmp.js"], "duration_sec": 100.3, "error": null, "strict": true, "lenient": true, "findings_scored": [{"fileMatch": true, "lineProx": true, "cweMatch": true, "hit": true, "title": "Path traversal / arbitrary file & directory write outside tmpdir via prefix-only startsWith check", "file": "lib/tmp.js", "line": 581, "cwe": "CWE-22"}], "n_findings": 1, "findings": [{"title": "Path traversal / arbitrary file & directory write outside tmpdir via prefix-only startsWith check", "file": "lib/tmp.js", "line": 581, "cwe": "CWE-22", "vuln_class": "path-traversal", "severity": "high", "description": "The containment check in _getRelativePath (line 581) and its sync twin _getRelativePathSync (line 600) validates that a user-supplied 'dir', 'template' or 'name' option stays inside tmpDir using `resolvedPath.startsWith(tmpDir)`. This is a prefix match without a trailing path separator. An attacker-controlled `dir` (e.g. '../<tmpDirBasename>EVIL/sub') resolves (via path.join(tmpDir, name) + fs.realpath in _resolvePath/_resolvePathSync) to a SIBLING directory whose absolute path begins with the tmpDir string but is NOT actually inside tmpDir. For tmpDir '/tmp/realtmp', resolvedPath '/tmp/realtmpEVIL/pwned' satisfies startsWith(tmpDir) === true, so the check passes. The (escaped) relative path is then stored as options.dir and used by _generateTmpName -> path.join(tmpDir, opts.dir, name), causing fs.open (file/fileSync), fs.mkdir (dir/dirSync), and especially the recursive rimraf/FN_RIMRAF_SYNC removal used with unsafeCleanup to operate on a location OUTSIDE the temp directory the consumer intended to confine operations to. Verified empirically: resolvedPath '/tmp/realtmpEVIL/pwned'.startsWith('/tmp/realtmp') === true.", "poc": "const tmp = require('tmp'); // assume os.tmpdir() realpaths to '/private/tmp' or any dir; create sibling sharing prefix\n// tmpDir basename is e.g. 'mytmp' -> attacker provides a dir option escaping to a prefix-sharing sibling\ntmp.dirSync({ tmpdir: '/tmp/mytmpEVIL/inner', unsafeCleanup: true });\n// The startsWith('/tmp/tmp/inner',\n// so node-tmp creates (and on cleanup recursively rm -rf's) a directory OUTSIDE the intended tmpDir.\n// Equivalent file write: tmp.fileSync({ tmpdir:'/t/mytmp', name:undefined, dir:'../mytmpEVIL/x', template:'fileXXXXXX' });", "confidence": 0.85}], "transcript": ""}
|
||||
{"id": "GHSA-ph9p-34f9-6g65", "cve": "CVE-2026-44705", "repo": "raszi/node-tmp", "cwes": ["CWE-22"], "vuln_files": ["lib/tmp.js"], "duration_sec": 48, "error": null, "strict": true, "lenient": true, "findings_scored": [{"fileMatch": true, "lineProx": true, "cweMatch": true, "hit": true, "title": "Path traversal / arbitrary file & directory creation via insufficient tmpDir boundary check (startsWith)", "file": "lib/tmp.js", "line": 581, "cwe": "CWE-22"}], "n_findings": 1, "findings": [{"title": "Path traversal / arbitrary file & directory creation via insufficient tmpDir boundary check (startsWith)", "file": "lib/tmp.js", "line": 581, "cwe": "CWE-22", "vuln_class": "path-traversal", "severity": "high", "description": "The containment check that is supposed to keep the resolved `dir`/`template`/`name` inside the temp root uses a bare string prefix test `resolvedPath.startsWith(tmpDir)` (line 581 in the async `_getRelativePath`, and identically line 600 in `_getRelativePathSync`). This does not enforce a path-separator boundary. Because `tmpDir` is derived from `fs.realpath(os.tmpdir())` (e.g. `/tmp`), any resolved path that merely shares that string prefix \u2014 such as the sibling directory `/tmpXXXX` reached via `dir: '../tmpXXXX'` \u2014 passes the check even though it lies OUTSIDE the temp root. The attacker-controlled `options.dir` (and `options.template`/`options.name`) flow: dir()/file()/dirSync()/fileSync() -> _assertAndSanitizeOptions[Sync] -> _getRelativePath[Sync]('dir', options.dir, tmpDir) -> _resolvePath (which joins `../tmpXXXX` onto tmpDir producing `/tmpXXXX` and returns realpath of its parent) -> the flawed startsWith gate. The escaped relative path is then stored in options.dir and consumed by _generateTmpName via `path.join(tmpDir, opts.dir, name)`, after which fs.mkdir / fs.open create a directory/file at the escaped location. Combined with symlinks under the tmp dir (which fs.realpath follows in _resolvePath), an attacker who controls the dir/template option can cause files/directories to be created outside the intended temp root (arbitrary write / directory creation, potential overwrite/cleanup outside tmp). This is CVE-2025-54798. The correct check must append path.sep (e.g. `resolvedPath !== tmpDir && !resolvedPath.startsWith(tmpDir + path.sep)`).", "poc": "Assume os.tmpdir() resolves to /tmp and a sibling dir /tmpbackdoor exists (or create the escape via a symlink placed inside /tmp). Call: require('tmp').dirSync({ dir: '../tmpbackdoor' }) \u2014 _resolvePathSync yields /tmpbackdoor, path.relative('/tmp','/tmpbackdoor') = '../tmpbackdoor', and '/tmpbackdoor'.startsWith('/tmp') === true, so the guard is bypassed and fs.mkdirSync creates a directory outside the temp root. Same works for tmp.fileSync({ template: '../tmpbackdoorXXXXXX' }) to create a file outside /tmp.", "confidence": 0.9}], "transcript": ""}
|
||||
@@ -1 +1 @@
|
||||
{"id": "GHSA-rg3m-cfq7-g6h6", "cve": "CVE-2026-43947", "repo": "frangoteam/FUXA", "cwes": ["CWE-863"], "vuln_files": ["server/api/command/index.js", "server/integrations/node-red/index.js"], "duration_sec": 102.9, "error": null, "strict": true, "lenient": true, "findings_scored": [{"fileMatch": true, "lineProx": true, "cweMatch": true, "hit": true, "title": "Node-RED admin auth bypass via unanchored URL substring match (\"/dashboard\") leading to RCE", "file": "server/integrations/node-red/index.js", "line": 160, "cwe": "CWE-287"}, {"fileMatch": true, "lineProx": false, "cweMatch": true, "hit": true, "title": "Node-RED SPA catch-all serves index from settings.httpStatic without sanitization (low)", "file": "server/integrations/node-red/index.js", "line": 280, "cwe": "CWE-285"}], "n_findings": 2, "findings": [{"title": "Node-RED admin auth bypass via unanchored URL substring match (\"/dashboard\") leading to RCE", "file": "server/integrations/node-red/index.js", "line": 160, "cwe": "CWE-287", "vuln_class": "auth-bypass", "severity": "critical", "description": "The allowDashboard middleware (mounted on app.use('/nodered', allowDashboard, RED.httpAdmin)) is the only authentication gate for the Node-RED admin/editor API; no adminAuth is configured in redSettings. At line 157 it builds url = req.originalUrl, which in Express includes the mount path AND the raw query string. Line 160 then grants public access with `if (url.includes('/dashboard') || url.includes('/socket.io')) return next();`. Because this is an unanchored substring test on the full URL+query, any request to a protected admin route that merely contains the substring '/dashboard' (or 'dashboard') anywhere\u2014including inside the query string\u2014short-circuits before the JWT / x-api-key validation, even when settings.secureEnabled is true. An unauthenticated attacker can therefore reach the full Node-RED admin REST API. Via POST /nodered/flows they can deploy a flow containing a Function node, which (with the broad functionGlobalContext exposing fs, fsSync, os, child_process when enabled, plus the FUXA runtime helpers like runScript/setTag) yields arbitrary file read/write and remote code execution on the FUXA host.", "poc": "1) Read flows unauthenticated: GET /nodered/flows?x=/dashboard HTTP/1.1 (url.includes('/dashboard') is true -> next() without token). 2) Deploy malicious flow unauthenticated: POST /nodered/flows?x=/dashboard with header 'Content-Type: application/json' and a flow array containing an inject node wired to a function node whose body uses the exposed global context, e.g. node 'func': {\"func\":\"const cp=global.get('child_process')||require('child_process'); cp.exec('id > /tmp/pwned'); return msg;\"} (or use fsSync to write a webshell). The deploy is accepted because authentication was bypassed, resulting in RCE.", "confidence": 0.9}, {"title": "Node-RED SPA catch-all serves index from settings.httpStatic without sanitization (low)", "file": "server/integrations/node-red/index.js", "line": 280, "cwe": "CWE-285", "vuln_class": "info-disclosure", "severity": "low", "description": "The same substring-based reasoning applies to the catch-all and exclusion logic using req.path.startsWith/includes; while less severe, the auth model around /nodered relies on fragile string matching. Secondary to the primary auth-bypass finding.", "poc": "GET /nodered/something?dashboard also bypasses the gate due to the same includes() check.", "confidence": 0.4}], "transcript": ""}
|
||||
{"id": "GHSA-rg3m-cfq7-g6h6", "cve": "CVE-2026-43947", "repo": "frangoteam/FUXA", "cwes": ["CWE-863"], "vuln_files": ["server/api/command/index.js", "server/integrations/node-red/index.js"], "duration_sec": 48.5, "error": null, "strict": true, "lenient": true, "findings_scored": [{"fileMatch": true, "lineProx": true, "cweMatch": true, "hit": true, "title": "Node-RED admin editor auth bypass via substring URL match (leads to RCE)", "file": "server/integrations/node-red/index.js", "line": 160, "cwe": "CWE-287"}, {"fileMatch": true, "lineProx": true, "cweMatch": true, "hit": true, "title": "getTagValue authorization can be bypassed via attacker-supplied sourceScriptName", "file": "server/api/command/index.js", "line": 80, "cwe": "CWE-639"}], "n_findings": 2, "findings": [{"title": "Node-RED admin editor auth bypass via substring URL match (leads to RCE)", "file": "server/integrations/node-red/index.js", "line": 160, "cwe": "CWE-287", "vuln_class": "auth-bypass", "severity": "critical", "description": "The `allowDashboard` middleware is the only authentication guard mounted in front of the Node-RED admin/editor API (`app.use('/nodered', allowDashboard, RED.httpAdmin)`). It builds `url = req.originalUrl || req.url || req.path` (which INCLUDES the query string) and then bypasses all authentication with `if (url.includes('/dashboard') || url.includes('/socket.io')) return next();`. Because `.includes()` is an unanchored substring match against the full URL (including the attacker-controlled query string), any request to the protected admin API can be made to satisfy the check simply by putting the literal string `/dashboard` (or `/socket.io`) anywhere in the query string. The intended prefix check (the public HTTP-node dashboard is served under httpNodeRoot `/dashboard`) is therefore trivially defeated, granting unauthenticated access to the Node-RED admin/editor endpoints under `/nodered`. Node-RED's admin API allows deploying flows containing `function` nodes that execute arbitrary JavaScript; combined with the exposed `functionGlobalContext` (fs, os, http, and optionally child_process, plus the FUXA `runtime`), this results in remote code execution / full server compromise. No JWT or API-key check is reached on the bypassed path.", "poc": "Unauthenticated attacker (secureEnabled=true, default auth mode) requests the Node-RED admin API with a query string containing the magic substring, e.g.:\n\n GET /nodered/settings?x=/dashboard HTTP/1.1\n\nor deploy a malicious flow:\n\n POST /nodered/flows?redirect=/dashboard HTTP/1.1\n Content-Type: application/json\n Node-RED-Deployment-Type: full\n [ {\"id\":\"1\",\"type\":\"tab\"}, {\"id\":\"2\",\"type\":\"inject\",\"z\":\"1\",\"props\":[{\"p\":\"payload\"}],\"once\":true,\"wires\":[[\"3\"]]}, {\"id\":\"3\",\"type\":\"function\",\"z\":\"1\",\"func\":\"require('child_process').exec('id > /tmp/pwned'); return msg;\",\"wires\":[[]]} ]\n\nThe `url.includes('/dashboard')` check passes, `next()` is called, the request hits RED.httpAdmin without any token validation, and the deployed function node executes attacker code on the server.", "confidence": 0.9}, {"title": "getTagValue authorization can be bypassed via attacker-supplied sourceScriptName", "file": "server/api/command/index.js", "line": 80, "cwe": "CWE-639", "vuln_class": "auth-bypass", "severity": "medium", "description": "The /api/getTagValue endpoint grants access if `runtime.scriptsMgr.isAuthorisedByScriptName(req.query.sourceScriptName, permission)` returns true. The script name is fully attacker-controlled via the query string, so a non-admin user can attempt to supply the name of any privileged script to satisfy the authorization check and read tag values they should not be able to access (info disclosure of process/SCADA tag values).", "poc": "GET /api/getTagValue?ids=[\"tag1\"]&sourceScriptName=<name-of-authorized-script> with a low-privilege token.", "confidence": 0.4}], "transcript": ""}
|
||||
@@ -1 +1 @@
|
||||
{"id": "GHSA-vv9j-gjw2-j8wp", "cve": "CVE-2026-42089", "repo": "yeoman/environment", "cwes": ["CWE-829"], "vuln_files": ["src/environment-full.ts"], "duration_sec": 220.2, "error": null, "strict": false, "lenient": false, "findings_scored": [], "n_findings": 0, "findings": [], "transcript": ""}
|
||||
{"id": "GHSA-vv9j-gjw2-j8wp", "cve": "CVE-2026-42089", "repo": "yeoman/environment", "cwes": ["CWE-829"], "vuln_files": ["src/environment-full.ts"], "duration_sec": 272.9, "error": null, "strict": false, "lenient": false, "findings_scored": [{"fileMatch": false, "lineProx": false, "cweMatch": false, "hit": false, "title": "ReDoS / quadratic-time regex in asNamespace() via `.replace(/(.*generator-)/, '')`", "file": "src/util/namespace.ts", "line": 67, "cwe": "CWE-1333"}], "n_findings": 1, "findings": [{"title": "ReDoS / quadratic-time regex in asNamespace() via `.replace(/(.*generator-)/, '')`", "file": "src/util/namespace.ts", "line": 67, "cwe": "CWE-1333", "vuln_class": "dos", "severity": "high", "description": "asNamespace() is invoked on attacker-influenced filepath/namespace strings (via Environment.namespace(), the lookup pipeline, and generator-lookup's asNamespace()). The cleanup step `ns.replace(/(.*generator-)/, '')` uses a greedy `.*` immediately followed by the literal `generator-`. When the input string does not contain the substring `generator-` (or contains near-matches), the regex engine must retry the greedy `.*` from many starting positions, producing quadratic O(n^2) time. Empirically: 50k chars => ~1s, 100k => ~4s, 200k => ~16s (confirmed via runtime measurement). A single long namespace/filepath supplied through the public `namespace()` API or a crafted file path processed during generator lookup blocks the Node.js event loop, causing denial of service.", "poc": "Call env.namespace('x'.repeat(200000)) (or asNamespace('x'.repeat(200000), {})), or have a package/file path of that length flow through lookupGenerators/lookupGenerator -> asNamespace. Each call hangs the event loop for tens of seconds; scaling the length increases the hang quadratically.", "confidence": 0.6}], "transcript": ""}
|
||||
@@ -1 +1 @@
|
||||
{"id": "GHSA-w4g9-mxgg-j532", "cve": "CVE-2026-46717", "repo": "nezhahq/nezha", "cwes": ["CWE-863", "CWE-918"], "vuln_files": ["model/notification.go", "model/notification_test.go"], "duration_sec": 104, "error": null, "strict": false, "lenient": false, "findings_scored": [{"fileMatch": false, "lineProx": false, "cweMatch": false, "hit": false, "title": "Nil-pointer dereference / division-by-zero in alert cycle-transfer rule evaluation (DoS)", "file": "model/rule.go", "line": 187, "cwe": "CWE-476"}, {"fileMatch": false, "lineProx": false, "cweMatch": false, "hit": false, "title": "Panic on slices.Max over empty slice for gpu_max / temperature_max rules (DoS)", "file": "model/rule.go", "line": 70, "cwe": "CWE-248"}, {"fileMatch": true, "lineProx": false, "cweMatch": false, "hit": false, "title": "Unescaped notification template substitution enables JSON/body injection in outbound notifications", "file": "model/notification.go", "line": 79, "cwe": "CWE-116"}], "n_findings": 3, "findings": [{"title": "Nil-pointer dereference / division-by-zero in alert cycle-transfer rule evaluation (DoS)", "file": "model/rule.go", "line": 187, "cwe": "CWE-476", "vuln_class": "dos", "severity": "high", "description": "Rule.CycleStart is a *time.Time tagged validate:\"optional\" and Rule.CycleInterval is an unvalidated uint64; both are populated directly from the alert-rule API form (AlertRuleForm.Rules -> Rule) and persisted/loaded via AlertRule.RulesRaw JSON (un)marshaling with no validation. In Rule.Snapshot, for any rule whose Type ends in '_cycle' (IsTransferDurationRule()==true), the block at lines 141-163 ALWAYS calls u.GetTransferDurationStart() and u.GetTransferDurationEnd() at lines 161-162 \u2014 independently of the 'if u.CycleInterval != 0' guards used earlier (lines 97/104/111). GetTransferDurationStart() begins with 'startTime := *u.CycleStart' (line 187): if cycle_start was omitted in the rule JSON, this dereferences a nil *time.Time and panics. The same functions also compute 'interval := 3600 * int64(u.CycleInterval)' and divide by it in the default/hour branch (lines 217/257); with cycle_interval==0 this is an integer division by zero panic. Either panic aborts the periodic alert-evaluation goroutine that runs Snapshot for every monitored server, taking down monitoring/alerting (availability) for the whole dashboard.", "poc": "As an authenticated user create/save an alert rule with a rule object: {\"rules\":[{\"type\":\"transfer_in_cycle\",\"max\":1}]} (no cycle_start, cycle_interval=0, cycle_unit unset). On the next alert tick the server evaluates Rule.Snapshot -> GetTransferDurationStart() -> '*u.CycleStart' nil dereference panic (and/or 3600*0 division-by-zero in the default unit branch), crashing the alerting loop.", "confidence": 0.78}, {"title": "Panic on slices.Max over empty slice for gpu_max / temperature_max rules (DoS)", "file": "model/rule.go", "line": 70, "cwe": "CWE-248", "vuln_class": "dos", "severity": "medium", "description": "In Rule.Snapshot, the 'gpu_max' branch calls slices.Max(server.State.GPU) (line 70) and the 'temperature_max' branch calls slices.Max(temp) (line 136). Go's slices.Max panics when given a zero-length slice. server.State.GPU and Temperatures originate from agent-reported HostState (PB2State) and are not guaranteed non-empty; an agent reporting no GPU/temperature data combined with an admin-configured 'gpu_max'/'temperature_max' alert rule causes slices.Max to panic and crash the alert-evaluation goroutine.", "poc": "Configure an alert rule {\"rules\":[{\"type\":\"gpu_max\",\"max\":1}]} for a server whose agent reports no GPU data (empty GPU slice). On evaluation, slices.Max([]) panics.", "confidence": 0.5}, {"title": "Unescaped notification template substitution enables JSON/body injection in outbound notifications", "file": "model/notification.go", "line": 79, "cwe": "CWE-116", "vuln_class": "info-disclosure", "severity": "low", "description": "reqBody()'s Form branch (line 79) calls replaceParamsInString with a nil mod function, so attacker-influenced server fields (e.g. Server.Name, public note, agent-reported metrics) are substituted into form/body parameters without escaping. Combined with attacker-controlled notification URL/headers (setRequestHeader sets arbitrary headers, Send may skip TLS verification), this allows content injection into outbound requests, though it requires notification configuration privileges.", "poc": "Set a server name containing form-breaking characters and a Form-type notification whose body references #SERVER.NAME#; the value is inserted unescaped into the urlencoded body.", "confidence": 0.2}], "transcript": ""}
|
||||
{"id": "GHSA-w4g9-mxgg-j532", "cve": "CVE-2026-46717", "repo": "nezhahq/nezha", "cwes": ["CWE-863", "CWE-918"], "vuln_files": ["model/notification.go", "model/notification_test.go"], "duration_sec": 155.7, "error": null, "strict": false, "lenient": false, "findings_scored": [{"fileMatch": false, "lineProx": false, "cweMatch": false, "hit": false, "title": "Nil pointer dereference in Rule.GetTransferDurationStart via cycle rule without cycle_start (dashboard DoS)", "file": "model/rule.go", "line": 187, "cwe": "CWE-476"}, {"fileMatch": false, "lineProx": false, "cweMatch": false, "hit": false, "title": "Integer divide-by-zero in Rule.GetTransferDurationStart/End for hour-unit cycle rule with cycle_interval=0 (dashboard DoS)", "file": "model/rule.go", "line": 217, "cwe": "CWE-369"}], "n_findings": 2, "findings": [{"title": "Nil pointer dereference in Rule.GetTransferDurationStart via cycle rule without cycle_start (dashboard DoS)", "file": "model/rule.go", "line": 187, "cwe": "CWE-476", "vuln_class": "dos", "severity": "high", "description": "Rule.CycleStart is a *time.Time tagged `validate:\"optional\"`, so it may be nil/omitted when a user creates an AlertRule. In Rule.Snapshot, once a rule's Type ends with '_cycle' (IsTransferDurationRule() == true), the block at rule.go:141 is entered unconditionally (independent of Max/Min/CycleInterval) and calls GetTransferDurationStart() at line 161 and GetTransferDurationEnd() at line 162. GetTransferDurationStart begins with `startTime := *u.CycleStart` (line 187), dereferencing the nil pointer and panicking. The AlertRule rules are attacker-controlled: they are supplied as JSON via the alert-rule create/update API (persisted to RulesRaw, reloaded in AlertRule.AfterFind). Snapshot is invoked by the alert-checking loop whenever a monitored server sends a state report, so the panic crashes the dashboard's alerting goroutine/process. No validation anywhere enforces a non-nil CycleStart for cycle-type rules.", "poc": "As an authenticated user, create an alert rule whose rules contain a cycle-type entry with no cycle_start, e.g. POST /api/v1/alert-rule with body: {\"name\":\"x\",\"rules\":[{\"type\":\"transfer_in_cycle\",\"max\":1,\"cycle_unit\":\"hour\",\"cycle_interval\":1}]} (note: cycle_start omitted -> nil). On the next server state report, Rule.Snapshot -> GetTransferDurationStart dereferences nil *CycleStart and panics, taking down the dashboard.", "confidence": 0.82}, {"title": "Integer divide-by-zero in Rule.GetTransferDurationStart/End for hour-unit cycle rule with cycle_interval=0 (dashboard DoS)", "file": "model/rule.go", "line": 217, "cwe": "CWE-369", "vuln_class": "dos", "severity": "high", "description": "For a cycle-type rule with CycleUnit empty or 'hour' (the default branch), GetTransferDurationStart computes `interval := 3600 * int64(u.CycleInterval)` (line 216) and then divides by `interval` (line 217: `(time.Now().Unix()-u.CycleStart.Unix())/interval*interval`). CycleInterval is tagged `validate:\"optional\"` and defaults to 0, so interval == 0 causes an integer divide-by-zero panic. Reached unconditionally from Rule.Snapshot line 161 for any '_cycle' rule (the CycleInterval!=0 guards at lines 97/104/111 do NOT protect the line-161 call). Same defect in GetTransferDurationEnd line 257. Attacker-controlled rule JSON triggers a dashboard-wide panic on server state report.", "poc": "Create alert rule: {\"name\":\"x\",\"rules\":[{\"type\":\"transfer_out_cycle\",\"max\":1,\"cycle_start\":\"2024-01-01T00:00:00Z\",\"cycle_unit\":\"hour\",\"cycle_interval\":0}]}. Next server report -> GetTransferDurationStart divides by zero -> panic/DoS.", "confidence": 0.6}], "transcript": ""}
|
||||
@@ -0,0 +1 @@
|
||||
{"schema": "t3mp3st.bench.cve-zero-v2.holdout/v1", "model": "opus-4-8", "effort": "xhigh", "split": "holdout", "built": "2026-07-04", "n": 10, "method": "Full-power pack-hunt on fresh post-cutoff 2026 CVEs the hardened prompts never saw. swarm = 10 hardened specialist classes, findings pooled (no skeptic filter); solo = 1 generalist. Both blind (no ground truth given to the agents).", "scoring": "verify-claims RECOMPUTES strict/lenient/anyHit from these raw findings + committed ground-truth.json via scoreSample/scoreFinding (top-3 by severity; hit = file-match AND (cwe-family OR line\u00b115)). Nothing here is a trusted stored score.", "caveats": "n=10 directional; swarm runs 10 specialists vs solo 1 (recall edge); this config pools without the skeptic used in the cli-swarm bake-off \u2014 NOT a coordination-beats-solo proof. The clean claim is generalization: fresh, unseen, cross-language.", "perSample": [{"id": "GHSA-2fp4-5v5c-4448", "solo": [{"title": "Path traversal in playlist cover-art lookup: attacker-controlled playlist ID resolves to arbitrary filesystem directory outside the playlist base path", "file": "server/ctrlsubsonic/handlers_raw.go", "line": 245, "cwe": "CWE-22", "severity": "high"}, {"title": "Nil pointer dereference (DoS) in getAvatar when an unknown username is supplied", "file": "server/ctrlsubsonic/handlers_raw.go", "line": 352, "cwe": "CWE-476", "severity": "low"}], "swarm": [{"title": "Path traversal in playlist cover art: attacker-controlled base64 id escapes playlist dir, enabling arbitrary directory listing and file read", "file": "server/ctrlsubsonic/handlers_raw.go", "line": 245, "cwe": "CWE-22", "severity": "high"}, {"title": "Path traversal via base64-encoded playlist specid (arbitrary file/dir read & write)", "file": "server/ctrlsubsonic/handlers_playlist.go", "line": 197, "cwe": "CWE-22", "severity": "high"}, {"title": "Unbounded user-controlled slice index in songIndexToRemove causes index-out-of-range panic (DoS)", "file": "server/ctrlsubsonic/handlers_playlist.go", "line": 156, "cwe": "CWE-129", "severity": "low"}, {"title": "getPlaylist: missing playlist ownership/visibility check lets any authenticated user read any private playlist (IDOR)", "file": "server/ctrlsubsonic/handlers_playlist.go", "line": 57, "cwe": "CWE-639", "severity": "high"}, {"title": "deletePlaylist: no ownership check lets any authenticated user delete any user's playlist (IDOR)", "file": "server/ctrlsubsonic/handlers_playlist.go", "line": 183, "cwe": "CWE-639", "severity": "high"}, {"title": "Non-constant-time comparison of stored password enables timing side-channel (CWE-208)", "file": "server/ctrlsubsonic/ctrl.go", "line": 293, "cwe": "CWE-208", "severity": "low"}, {"title": "Non-constant-time comparison of authentication token (CWE-208)", "file": "server/ctrlsubsonic/ctrl.go", "line": 285, "cwe": "CWE-208", "severity": "low"}, {"title": "Arbitrary file write via playlist path traversal (base64 playlist id \u2192 unvalidated filesystem path)", "file": "server/ctrlsubsonic/handlers_playlist.go", "line": 110, "cwe": "CWE-22", "severity": "critical"}, {"title": "Arbitrary file delete via playlist path traversal in ServeDeletePlaylist (no ownership or containment check)", "file": "server/ctrlsubsonic/handlers_playlist.go", "line": 183, "cwe": "CWE-22", "severity": "high"}, {"title": "Arbitrary file read via playlist path traversal (ServeGetPlaylist Read / coverForPlaylist os.Open)", "file": "server/ctrlsubsonic/handlers_playlist.go", "line": 57, "cwe": "CWE-22", "severity": "high"}, {"title": "Unvalidated JSONP `callback` parameter reflected into executable JavaScript response", "file": "server/ctrlsubsonic/ctrl.go", "line": 341, "cwe": "CWE-79", "severity": "low"}, {"title": "Arbitrary file read: getCoverArt playlist cover lookup joins unsanitized traversal path and serves the file", "file": "server/ctrlsubsonic/handlers_podcast.go", "line": 245, "cwe": "CWE-22", "severity": "high"}, {"title": "Broken access control: any authenticated user can read any user's private playlist via /getPlaylist (missing ownership/public check)", "file": "server/ctrlsubsonic/handlers_playlist.go", "line": 51, "cwe": "CWE-639", "severity": "high"}, {"title": "Full-read SSRF: unvalidated http.Get of externally-sourced artist image URL with response reflected to caller", "file": "server/ctrlsubsonic/handlers_raw.go", "line": 184, "cwe": "CWE-918", "severity": "high"}, {"title": "Directory listing and cover-file read via playlist-cover path traversal", "file": "server/ctrlsubsonic/handlers_common.go", "line": 245, "cwe": "CWE-22", "severity": "medium"}, {"title": "Podcast episode download triggers privileged server-side fetch/disk-write with no admin/authorization gate", "file": "server/ctrlsubsonic/handlers_podcast.go", "line": 49, "cwe": "CWE-862", "severity": "medium"}]}, {"id": "GHSA-q6xx-5vr8-p898", "solo": [{"title": "Terminal WebSocket stream (/ws/terminal/{id}) performs no ownership or server-permission check, onlLine truncated
|
||||
@@ -0,0 +1 @@
|
||||
{"ghsa": "GHSA-2fp4-5v5c-4448", "repo": "sentriz/gonic", "cwes": ["CWE-22", "CWE-639"], "summary": "fix(subsonic): enforce playlist ownership on getPlaylist/deletePlaylist", "fix_commit": "6dd71e6a3c966867ef8c900d359a7df75789f410", "parent_commit": "37090aa71c5e0bb7a322313fc8622c828fd9916b", "vuln_files": ["server/ctrlsubsonic/handlers_playlist.go"], "vuln_lines": {"server/ctrlsubsonic/handlers_playlist.go": [51, 60, 177, 183]}, "source_files": 156, "scope_dirs": ["server/ctrlsubsonic"], "source_note": "Subset: only the server/ctrlsubsonic package (containing the vuln file) was copied into source/, not the full repo."}
|
||||
@@ -1,145 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const Boom = require('@hapi/boom');
|
||||
|
||||
|
||||
const internals = {};
|
||||
|
||||
|
||||
/*
|
||||
RFC 7231 Section 3.1.1.1
|
||||
|
||||
media-type = type "/" subtype *( OWS ";" OWS parameter )
|
||||
type = token
|
||||
subtype = token
|
||||
parameter = token "=" ( token / quoted-string )
|
||||
*/
|
||||
|
||||
// 1: type/subtype 2: params
|
||||
internals.contentTypeRegex = /^([^\/\s]+\/[^\s;]+)([ \t;][^\r\n]*)?$/;
|
||||
|
||||
// 1: "b" 2: b
|
||||
internals.charsetParamRegex = /;\s*charset=(?:"([^"]+)"|([^;"\s]+))/i;
|
||||
|
||||
// 1: "b" 2: b
|
||||
internals.boundaryParamRegex = /;\s*boundary=(?:"([^"]+)"|([^;"\s]+))/i;
|
||||
|
||||
|
||||
exports.type = function (header) {
|
||||
|
||||
if (!header) {
|
||||
throw Boom.badRequest('Invalid content-type header');
|
||||
}
|
||||
|
||||
const match = header.match(internals.contentTypeRegex);
|
||||
if (!match) {
|
||||
throw Boom.badRequest('Invalid content-type header');
|
||||
}
|
||||
|
||||
const result = {
|
||||
mime: match[1].toLowerCase()
|
||||
};
|
||||
|
||||
const params = match[2];
|
||||
if (params) {
|
||||
const param = params.match(internals.charsetParamRegex);
|
||||
if (param) {
|
||||
result.charset = (param[1] || param[2]).toLowerCase();
|
||||
}
|
||||
}
|
||||
|
||||
if (result.mime.indexOf('multipart/') === 0) {
|
||||
if (params) {
|
||||
const param = params.match(internals.boundaryParamRegex);
|
||||
if (param) {
|
||||
result.boundary = param[1] || param[2];
|
||||
}
|
||||
}
|
||||
|
||||
if (!result.boundary) {
|
||||
throw Boom.badRequest('Invalid content-type header: multipart missing boundary');
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
|
||||
/*
|
||||
RFC 6266 Section 4.1 (http://tools.ietf.org/html/rfc6266#section-4.1)
|
||||
|
||||
content-disposition = "Content-Disposition" ":" disposition-type *( ";" disposition-parm )
|
||||
disposition-type = "inline" | "attachment" | token ; case-insensitive
|
||||
disposition-parm = filename-parm | token [ "*" ] "=" ( token | quoted-string | ext-value) ; ext-value defined in [RFC5987], Section 3.2
|
||||
|
||||
Content-Disposition header field values with multiple instances of the same parameter name are invalid.
|
||||
|
||||
Note that due to the rules for implied linear whitespace (Section 2.1 of [RFC2616]), OPTIONAL whitespace
|
||||
can appear between words (token or quoted-string) and separator characters.
|
||||
|
||||
Furthermore, note that the format used for ext-value allows specifying a natural language (e.g., "en"); this is of limited use
|
||||
for filenames and is likely to be ignored by recipients.
|
||||
*/
|
||||
|
||||
|
||||
internals.contentDispositionRegex = /^\s*form-data\s*(?:;\s*(\S.*))?$/i;
|
||||
|
||||
// 1: name 2: * 3: ext-value 4: quoted 5: token
|
||||
internals.contentDispositionParamRegex = /([^\=\*\s]+)(\*)?\s*\=\s*(?:([^;'"\s]+\'[\w-]*\'[^;\s]+)|(?:\"([^"]*)\")|([^;\s]*))/g;
|
||||
|
||||
exports.disposition = function (header) {
|
||||
|
||||
if (!header) {
|
||||
throw Boom.badRequest('Missing content-disposition header');
|
||||
}
|
||||
|
||||
const match = header.match(internals.contentDispositionRegex);
|
||||
if (!match) {
|
||||
throw Boom.badRequest('Invalid content-disposition header format');
|
||||
}
|
||||
|
||||
const parameters = match[1];
|
||||
if (!parameters) {
|
||||
throw Boom.badRequest('Invalid content-disposition header missing parameters');
|
||||
}
|
||||
|
||||
const result = {};
|
||||
parameters.replace(internals.contentDispositionParamRegex, ($0, $1, $2, $3, $4, $5) => {
|
||||
|
||||
if ($1 === '__proto__') {
|
||||
throw Boom.badRequest('Invalid content-disposition header format includes invalid parameters');
|
||||
}
|
||||
|
||||
let value;
|
||||
|
||||
if ($2) {
|
||||
if (!$3) {
|
||||
throw Boom.badRequest('Invalid content-disposition header format includes invalid parameters');
|
||||
}
|
||||
|
||||
try {
|
||||
value = decodeURIComponent($3.split('\'')[2]);
|
||||
}
|
||||
catch (err) {
|
||||
throw Boom.badRequest('Invalid content-disposition header format includes invalid parameters');
|
||||
}
|
||||
}
|
||||
else {
|
||||
value = $4 || $5 || '';
|
||||
}
|
||||
|
||||
if ($1 === 'name' &&
|
||||
value === '__proto__') {
|
||||
|
||||
throw Boom.badRequest('Invalid content-disposition header format includes invalid parameters');
|
||||
}
|
||||
|
||||
result[$1] = value;
|
||||
});
|
||||
|
||||
if (!result.name) {
|
||||
throw Boom.badRequest('Invalid content-disposition header missing name parameter');
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
-216
@@ -1,216 +0,0 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/users"
|
||||
)
|
||||
|
||||
// TestApplyUserDefaults_copiesUserDefaultsOntoUser guards against drift between
|
||||
// [UserDefaults] and [ApplyUserDefaults] (regression for #2278-style bugs).
|
||||
func TestApplyUserDefaults_copiesUserDefaultsOntoUser(t *testing.T) {
|
||||
saved := Config
|
||||
defer func() { Config = saved }()
|
||||
|
||||
dark := false
|
||||
dlFalse := false
|
||||
Config = Settings{
|
||||
Server: Server{},
|
||||
UserDefaults: UserDefaults{
|
||||
Sidebar: UserDefaultsSidebar{
|
||||
DisableQuickToggles: true,
|
||||
HideFileActions: true,
|
||||
DisableHideOnPreview: true,
|
||||
Sticky: false,
|
||||
HideFiles: true,
|
||||
ShowTools: boolPtr(true),
|
||||
},
|
||||
Listing: UserDefaultsListing{
|
||||
DeleteWithoutConfirming: true,
|
||||
DateFormat: true,
|
||||
ShowHidden: true,
|
||||
QuickDownload: true,
|
||||
ShowSelectMultiple: true,
|
||||
SingleClick: true,
|
||||
HideFileExt: "",
|
||||
ShowCopyPath: false,
|
||||
DeleteAfterArchive: false,
|
||||
ViewMode: "list",
|
||||
GallerySize: 7,
|
||||
},
|
||||
Preview: UserDefaultsPreview{
|
||||
Image: boolPtr(false),
|
||||
Video: boolPtr(false),
|
||||
Audio: boolPtr(false),
|
||||
MotionVideoPreview: boolPtr(false),
|
||||
Office: boolPtr(false),
|
||||
PopUp: boolPtr(false),
|
||||
DisablePreviewExt: ".exe",
|
||||
HighQuality: boolPtr(true),
|
||||
Folder: boolPtr(false),
|
||||
Models: boolPtr(false),
|
||||
},
|
||||
FileViewer: UserDefaultsFileViewer{
|
||||
EditorQuickSave: true,
|
||||
AutoplayMedia: boolPtr(false),
|
||||
DisableViewingExt: ".bat",
|
||||
DisableOnlyOfficeExt: ".x",
|
||||
PreferEditorForMarkdown: true,
|
||||
DebugOffice: true,
|
||||
DefaultMediaPlayer: true,
|
||||
},
|
||||
Search: UserDefaultsSearch{
|
||||
DisableOptions: true,
|
||||
},
|
||||
UI: UserDefaultsUI{
|
||||
DarkMode: &dark,
|
||||
ThemeColor: "var(--red)",
|
||||
CustomTheme: "Night",
|
||||
Locale: "de",
|
||||
},
|
||||
FileLoading: users.FileLoading{
|
||||
MaxConcurrent: 3,
|
||||
UploadChunkSize: 5,
|
||||
DownloadChunkSize: 2,
|
||||
},
|
||||
Account: UserDefaultsAccount{
|
||||
Permissions: UserDefaultsAccountPermissions{
|
||||
Api: true,
|
||||
Admin: true,
|
||||
Modify: true,
|
||||
Share: true,
|
||||
Realtime: true,
|
||||
Delete: true,
|
||||
Create: true,
|
||||
Download: &dlFalse,
|
||||
},
|
||||
LockPassword: true,
|
||||
DisableSettings: true,
|
||||
LoginMethod: "password",
|
||||
DisableUpdateNotifications: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
u := &users.User{Username: "alice"}
|
||||
ApplyUserDefaults(u)
|
||||
|
||||
want := users.User{
|
||||
DisableSettings: true,
|
||||
LockPassword: true,
|
||||
LoginMethod: users.LoginMethodPassword,
|
||||
Permissions: users.Permissions{
|
||||
Api: true,
|
||||
Admin: true,
|
||||
Modify: true,
|
||||
Share: true,
|
||||
Realtime: true,
|
||||
Delete: true,
|
||||
Create: true,
|
||||
Download: false,
|
||||
},
|
||||
NonAdminEditable: users.NonAdminEditable{
|
||||
EditorQuickSave: true,
|
||||
HideSidebarFileActions: true,
|
||||
DisableQuickToggles: true,
|
||||
DisableSearchOptions: true,
|
||||
StickySidebar: false,
|
||||
HideFilesInTree: true,
|
||||
DarkMode: false,
|
||||
Locale: "de",
|
||||
ViewMode: "list",
|
||||
SingleClick: true,
|
||||
ShowHidden: true,
|
||||
DateFormat: true,
|
||||
GallerySize: 7,
|
||||
ThemeColor: "var(--red)",
|
||||
QuickDownload: true,
|
||||
DisablePreviewExt: ".exe",
|
||||
DisableViewingExt: ".bat",
|
||||
DisableUpdateNotifications: true,
|
||||
DisableOfficePreviewExt: ".x", // deprecated: mapped to DisableOnlyOfficeExt
|
||||
DisableOnlyOfficeExt: ".x",
|
||||
CustomTheme: "Night",
|
||||
ShowSelectMultiple: true,
|
||||
DebugOffice: true,
|
||||
DeleteWithoutConfirming: true,
|
||||
DeleteAfterArchive: false,
|
||||
PreferEditorForMarkdown: true,
|
||||
ShowToolsInSidebar: true,
|
||||
FileLoading: users.FileLoading{
|
||||
MaxConcurrent: 3,
|
||||
UploadChunkSize: 5,
|
||||
DownloadChunkSize: 2,
|
||||
},
|
||||
Preview: users.Preview{
|
||||
DisableHideSidebar: true,
|
||||
Image: false,
|
||||
Video: false,
|
||||
Audio: false,
|
||||
MotionVideoPreview: false,
|
||||
Office: false,
|
||||
PopUp: false,
|
||||
AutoplayMedia: false,
|
||||
DefaultMediaPlayer: true,
|
||||
Folder: false,
|
||||
Models: false,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if diff := cmp.Diff(want.Permissions, u.Permissions); diff != "" {
|
||||
t.Fatalf("Permissions mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
if diff := cmp.Diff(want.NonAdminEditable, u.NonAdminEditable); diff != "" {
|
||||
t.Fatalf("NonAdminEditable mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
if u.DisableSettings != want.DisableSettings {
|
||||
t.Fatalf("DisableSettings: got %v want %v", u.DisableSettings, want.DisableSettings)
|
||||
}
|
||||
if u.LockPassword != want.LockPassword {
|
||||
t.Fatalf("LockPassword: got %v want %v", u.LockPassword, want.LockPassword)
|
||||
}
|
||||
if u.LoginMethod != want.LoginMethod {
|
||||
t.Fatalf("LoginMethod: got %q want %q", u.LoginMethod, want.LoginMethod)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyUserDefaults_setsLoginMethodWhenEmpty(t *testing.T) {
|
||||
saved := Config
|
||||
defer func() { Config = saved }()
|
||||
|
||||
Config = Settings{
|
||||
Server: Server{},
|
||||
UserDefaults: UserDefaults{
|
||||
Account: UserDefaultsAccount{
|
||||
LoginMethod: "jwt",
|
||||
},
|
||||
},
|
||||
}
|
||||
u := &users.User{Username: "x"}
|
||||
ApplyUserDefaults(u)
|
||||
if u.LoginMethod != users.LoginMethodJwt {
|
||||
t.Fatalf("LoginMethod: got %q want jwt", u.LoginMethod)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyUserDefaults_preservesLoginMethodWhenAlreadySet(t *testing.T) {
|
||||
saved := Config
|
||||
defer func() { Config = saved }()
|
||||
|
||||
Config = Settings{
|
||||
Server: Server{},
|
||||
UserDefaults: UserDefaults{
|
||||
Account: UserDefaultsAccount{
|
||||
LoginMethod: "password",
|
||||
},
|
||||
},
|
||||
}
|
||||
u := &users.User{Username: "x", LoginMethod: users.LoginMethodProxy}
|
||||
ApplyUserDefaults(u)
|
||||
if u.LoginMethod != users.LoginMethodProxy {
|
||||
t.Fatalf("LoginMethod: got %q want proxy", u.LoginMethod)
|
||||
}
|
||||
}
|
||||
@@ -1,308 +0,0 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
ldap "github.com/go-ldap/ldap/v3"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/version"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
|
||||
// userAgentTransport wraps an http.RoundTripper to add a User-Agent header
|
||||
type userAgentTransport struct {
|
||||
base http.RoundTripper
|
||||
userAgent string
|
||||
}
|
||||
|
||||
// RoundTrip implements the http.RoundTripper interface
|
||||
func (t *userAgentTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
// Clone the request to avoid modifying the original
|
||||
reqCopy := req.Clone(req.Context())
|
||||
// Set User-Agent header if not already set
|
||||
if reqCopy.Header.Get("User-Agent") == "" {
|
||||
reqCopy.Header.Set("User-Agent", t.userAgent)
|
||||
}
|
||||
return t.base.RoundTrip(reqCopy)
|
||||
}
|
||||
|
||||
// AuthCommon contains fields shared across multiple authentication methods
|
||||
type AuthCommon struct {
|
||||
Enabled bool `json:"enabled"` // whether to enable this authentication method.
|
||||
AdminGroup string `json:"adminGroup"` // if set, users in this group will be granted admin privileges
|
||||
UserGroups []string `json:"userGroups"` // if set, only users in these groups are allowed to log in. Blocks all other users even with valid credentials.
|
||||
GroupsClaim string `json:"groupsClaim"` // the JSON field name to read groups from. Default is "groups"
|
||||
UserIdentifier string `json:"userIdentifier"` // the field value to use as the username. Default is "preferred_username" in oidc, "sub" in jwt. Other common values are "email" or "username", or "phone"
|
||||
DisableVerifyTLS bool `json:"disableVerifyTLS"` // disable TLS verification (insecure, for testing only)
|
||||
LogoutRedirectUrl string `json:"logoutRedirectUrl"` // if provider logout url is provided, filebrowser will also redirect to logout url. Custom logout query params are respected.
|
||||
CreateUser bool `json:"createUser"` // deprecated: always true for supported authentication methods
|
||||
}
|
||||
|
||||
type Auth struct {
|
||||
TokenExpirationHours int `json:"tokenExpirationHours"` // time in hours each web UI session token is valid for. Default is 2 hours.
|
||||
Methods LoginMethods `json:"methods"`
|
||||
Key string `json:"key"` // secret: the key used to sign the JWT tokens. If not set, a random key will be generated.
|
||||
AdminUsername string `json:"adminUsername"` // secret: the username of the admin user. If not set, the default is "admin".
|
||||
AdminPassword string `json:"adminPassword"` // secret: the password of the admin user. If not set, the default is "admin".
|
||||
TotpSecret string `json:"totpSecret"` // secret: secret used to encrypt TOTP secrets
|
||||
AuthMethods []string `json:"-"`
|
||||
}
|
||||
|
||||
type LoginMethods struct {
|
||||
ProxyAuth ProxyAuthConfig `json:"proxy" validate:"omitempty"`
|
||||
NoAuth bool `json:"noauth"` // if set to true, overrides all other auth methods and disables authentication
|
||||
PasswordAuth PasswordAuthConfig `json:"password" validate:"omitempty"`
|
||||
OidcAuth OidcConfig `json:"oidc" validate:"omitempty"`
|
||||
LdapAuth LdapConfig `json:"ldap" validate:"omitempty"`
|
||||
JwtAuth JwtAuthConfig `json:"jwt" validate:"omitempty"`
|
||||
PasskeyAuth PasskeyAuthConfig `json:"passkey" validate:"omitempty"`
|
||||
}
|
||||
|
||||
type PasswordAuthConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
MinLength int `json:"minLength" validate:"omitempty"` // minimum pasword length required, default is 5.
|
||||
Signup bool `json:"signup" validate:"omitempty"` // allow signups on login page if enabled -- not secure.
|
||||
Recaptcha Recaptcha `json:"recaptcha" validate:"omitempty"` // recaptcha config, only used if signup is enabled
|
||||
EnforcedOtp bool `json:"enforcedOtp"` // if set to true, TOTP is enforced for all password users users. Otherwise, users can choose to enable TOTP.
|
||||
}
|
||||
|
||||
type ProxyAuthConfig struct {
|
||||
AuthCommon `json:",inline"`
|
||||
Header string `json:"header"` // required header to use for authentication. Security Warning: FileBrowser blindly accepts the header value as username.
|
||||
}
|
||||
|
||||
type Recaptcha struct {
|
||||
Host string `json:"host" validate:"required"`
|
||||
Key string `json:"key" validate:"required"`
|
||||
Secret string `json:"secret" validate:"required"`
|
||||
}
|
||||
|
||||
// OpenID OAuth2.0
|
||||
type OidcConfig struct {
|
||||
AuthCommon `json:",inline"`
|
||||
ClientID string `json:"clientId"` // secret: client id of the OIDC application
|
||||
ClientSecret string `json:"clientSecret"` // secret: client secret of the OIDC application
|
||||
IssuerUrl string `json:"issuerUrl"` // authorization URL of the OIDC provider
|
||||
Scopes string `json:"scopes"` // scopes to request from the OIDC provider
|
||||
Provider *oidc.Provider `json:"-"` // OIDC provider
|
||||
Verifier *oidc.IDTokenVerifier `json:"-"` // OIDC verifier
|
||||
}
|
||||
|
||||
type LdapConfig struct {
|
||||
AuthCommon `json:",inline"`
|
||||
Server string `json:"server"` // scheme://host:port of the LDAP server (e.g. ldap://localhost:389)
|
||||
BaseDN string `json:"baseDN"` // LDAP search base DN (e.g. dc=ldap,dc=goauthentik,dc=io)
|
||||
UserDN string `json:"userDN"` // Bind DN for service account (e.g. cn=admin,ou=users,dc=ldap,dc=goauthentik,dc=io)
|
||||
UserPassword string `json:"userPassword"` // Bind password for service account
|
||||
UserFilter string `json:"userFilter"` // Search filter for finding user by username. Default (&(cn=%s)(objectClass=user)); override e.g. (email=%s) or (sAMAccountName=%s) for other directories.
|
||||
Port int `json:"-"` // derived from server
|
||||
Scheme string `json:"-"` // derived from server
|
||||
Host string `json:"-"` // derived from server
|
||||
}
|
||||
|
||||
// JwtAuthConfig configures external JWT token authentication
|
||||
// Similar to Grafana's JWT auth: verifies HS* tokens with a shared secret, or RS*/ES* with a PEM public key (or certificate)
|
||||
// The query parameter is hardcoded to "jwt" (e.g. ?jwt=<token>)
|
||||
type JwtAuthConfig struct {
|
||||
AuthCommon `json:",inline"`
|
||||
Header string `json:"header"` // HTTP header to look for JWT token (e.g. X-JWT-Assertion). Default is "X-JWT-Assertion"
|
||||
Secret string `json:"secret"` // secret: Shared secret key/bytes for verifying JWT token signatures (required, eg PUBLIC KEY, RSA PUBLIC KEY, EC PUBLIC KEY, or CERTIFICATE)
|
||||
Algorithm string `json:"algorithm"` // JWT signing algorithm (HS256, HS384, HS512, RS256, ES256). Default is "HS256"
|
||||
}
|
||||
|
||||
// PasskeyAuthConfig configures WebAuthn / Passkey authentication as a second factor.
|
||||
type PasskeyAuthConfig struct {
|
||||
Enabled bool `json:"enabled"` // whether to enable passkey MFA
|
||||
RPDisplayName string `json:"rpDisplayName"` // the Relying Party display name (defaults to frontend name)
|
||||
RPID string `json:"rpId"` // the Relying Party ID; if empty, auto-derived from base URL host
|
||||
RPOrigins []string `json:"rpOrigins"` // allowed origins; if empty, auto-derived from base URL
|
||||
LoginButtonText string `json:"loginButtonText"` // custom text for the passkey login button
|
||||
}
|
||||
|
||||
// ValidateLdapAuth checks LDAP config and sets defaults. Call when LDAP is enabled.
|
||||
func ValidateLdapAuth() error {
|
||||
ldapCfg := &Config.Auth.Methods.LdapAuth
|
||||
if ldapCfg.Server == "" {
|
||||
return fmt.Errorf("LDAP server is required when LDAP is enabled")
|
||||
}
|
||||
scheme, host, port, err := parseLdapServer(ldapCfg.Server)
|
||||
if err != nil {
|
||||
return fmt.Errorf("LDAP server is invalid: %w", err)
|
||||
}
|
||||
ldapCfg.Server = fmt.Sprintf("%s://%s:%d", scheme, host, port)
|
||||
ldapCfg.Scheme = scheme
|
||||
ldapCfg.Host = host
|
||||
ldapCfg.Port = port
|
||||
|
||||
if ldapCfg.BaseDN == "" {
|
||||
return fmt.Errorf("LDAP baseDN is required when LDAP is enabled")
|
||||
}
|
||||
if ldapCfg.UserDN == "" {
|
||||
return fmt.Errorf("LDAP userDN (bind DN) is required when LDAP is enabled")
|
||||
}
|
||||
if ldapCfg.UserFilter == "" {
|
||||
ldapCfg.UserFilter = "(&(cn=%s)(objectClass=user))"
|
||||
}
|
||||
if err := verifyLdapConnection(); err != nil {
|
||||
logger.Fatalf("LDAP connection check failed: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateOidcAuth processes the OIDC callback and retrieves user identity
|
||||
func validateOidcAuth() error {
|
||||
oidcCfg := &Config.Auth.Methods.OidcAuth // Use a pointer to modify the original config
|
||||
if oidcCfg.GroupsClaim == "" {
|
||||
oidcCfg.GroupsClaim = "groups"
|
||||
}
|
||||
if oidcCfg.UserIdentifier == "" {
|
||||
oidcCfg.UserIdentifier = "preferred_username"
|
||||
}
|
||||
if oidcCfg.Scopes == "" {
|
||||
oidcCfg.Scopes = "openid email profile"
|
||||
}
|
||||
|
||||
if !oidcCfg.Enabled {
|
||||
return errors.New("OIDC is not enabled")
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
// Create a custom HTTP client with proper User-Agent to avoid being blocked by bot protection
|
||||
transport := &http.Transport{
|
||||
Proxy: http.ProxyFromEnvironment, // Respect HTTP_PROXY, HTTPS_PROXY, and NO_PROXY environment variables
|
||||
}
|
||||
|
||||
// If disableVerifyTLS is true, disable TLS verification
|
||||
if oidcCfg.DisableVerifyTLS {
|
||||
transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
|
||||
logger.Warning("OIDC TLS verification is disabled.")
|
||||
}
|
||||
|
||||
// Create custom client with User-Agent header to bypass bot protection (like Cloudflare Bot Fight Mode)
|
||||
customClient := &http.Client{
|
||||
Transport: &userAgentTransport{
|
||||
base: transport,
|
||||
userAgent: fmt.Sprintf("FileBrowser Quantum - %s (OIDC Client)", version.Version),
|
||||
},
|
||||
}
|
||||
|
||||
// Use oidc.ClientContext to pass the custom client to the OIDC library
|
||||
ctx = oidc.ClientContext(ctx, customClient)
|
||||
|
||||
provider, err := oidc.NewProvider(ctx, oidcCfg.IssuerUrl)
|
||||
if err != nil {
|
||||
return fmt.Errorf("url '%v' failed to create OIDC provider: %w", oidcCfg.IssuerUrl, err)
|
||||
}
|
||||
oidcCfg.Provider = provider
|
||||
oidcCfg.Verifier = provider.Verifier(&oidc.Config{ClientID: oidcCfg.ClientID})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseLdapServer(server string) (scheme, host string, port int, err error) {
|
||||
parts := strings.Split(server, "://")
|
||||
if len(parts) != 2 {
|
||||
return "", "", 0, fmt.Errorf("invalid LDAP server: %s", server)
|
||||
}
|
||||
scheme = strings.ToLower(parts[0])
|
||||
if scheme != "ldap" && scheme != "ldaps" {
|
||||
return "", "", 0, fmt.Errorf("invalid LDAP scheme: %s", scheme)
|
||||
}
|
||||
hostParts := strings.Split(parts[1], ":")
|
||||
host = strings.TrimSuffix(hostParts[0], "/")
|
||||
if len(hostParts) > 1 && hostParts[1] != "" {
|
||||
port, err = strconv.Atoi(strings.TrimSuffix(hostParts[1], "/"))
|
||||
if err != nil || port == 0 {
|
||||
return "", "", 0, fmt.Errorf("invalid LDAP port: %s", hostParts[1])
|
||||
}
|
||||
} else {
|
||||
if scheme == "ldaps" {
|
||||
port = 636
|
||||
} else {
|
||||
port = 389
|
||||
}
|
||||
}
|
||||
return scheme, host, port, nil
|
||||
}
|
||||
|
||||
// VerifyLdapConnection tests that the LDAP server is reachable, can bind (if credentials set), and responds to a search.
|
||||
func verifyLdapConnection() error {
|
||||
if Config.Auth.Methods.LdapAuth.Server == "" {
|
||||
return fmt.Errorf("LDAP server is required when LDAP is enabled")
|
||||
}
|
||||
if Config.Auth.Methods.LdapAuth.BaseDN == "" {
|
||||
return fmt.Errorf("LDAP baseDN is required for connection verification")
|
||||
}
|
||||
var opts []ldap.DialOpt
|
||||
scheme := Config.Auth.Methods.LdapAuth.Scheme
|
||||
host := Config.Auth.Methods.LdapAuth.Host
|
||||
port := Config.Auth.Methods.LdapAuth.Port
|
||||
if Config.Auth.Methods.LdapAuth.DisableVerifyTLS && scheme == "ldaps" {
|
||||
opts = append(opts, ldap.DialWithTLSConfig(&tls.Config{InsecureSkipVerify: true}))
|
||||
}
|
||||
fullURL := fmt.Sprintf("%s://%s:%d", scheme, host, port)
|
||||
conn, err := ldap.DialURL(fullURL, opts...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("LDAP connection failed (is the LDAP outpost running at %s? use port 389 for LDAP, 636 for LDAPS; this is not the HTTP port): %w", fullURL, err)
|
||||
}
|
||||
defer conn.Close()
|
||||
if Config.Auth.Methods.LdapAuth.UserDN != "" && Config.Auth.Methods.LdapAuth.UserPassword != "" {
|
||||
if err = conn.Bind(Config.Auth.Methods.LdapAuth.UserDN, Config.Auth.Methods.LdapAuth.UserPassword); err != nil {
|
||||
return fmt.Errorf("LDAP bind failed (check userDN and userPassword): %w", err)
|
||||
}
|
||||
defer func() { _ = conn.Unbind() }()
|
||||
}
|
||||
// Dummy search to verify the server responds to LDAP search (same path that fails at login if misconfigured).
|
||||
searchRequest := ldap.NewSearchRequest(
|
||||
Config.Auth.Methods.LdapAuth.BaseDN,
|
||||
ldap.ScopeBaseObject,
|
||||
ldap.NeverDerefAliases,
|
||||
0,
|
||||
0,
|
||||
false,
|
||||
"(objectClass=*)",
|
||||
[]string{"1.1"}, // no attributes
|
||||
nil,
|
||||
)
|
||||
_, err = conn.Search(searchRequest)
|
||||
if err != nil {
|
||||
return fmt.Errorf("LDAP search test failed (server may require StartTLS or bind before search): %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateJwtAuth checks JWT config and sets defaults. Call when JWT auth is enabled.
|
||||
func ValidateJwtAuth() error {
|
||||
jwtCfg := &Config.Auth.Methods.JwtAuth
|
||||
if jwtCfg.Secret == "" {
|
||||
return fmt.Errorf("JWT secret is required when JWT auth is enabled")
|
||||
}
|
||||
if jwtCfg.Header == "" {
|
||||
jwtCfg.Header = "X-JWT-Assertion"
|
||||
}
|
||||
if jwtCfg.Algorithm == "" {
|
||||
jwtCfg.Algorithm = "HS256"
|
||||
}
|
||||
if jwtCfg.GroupsClaim == "" {
|
||||
jwtCfg.GroupsClaim = "groups"
|
||||
}
|
||||
if jwtCfg.UserIdentifier == "" {
|
||||
jwtCfg.UserIdentifier = "sub"
|
||||
}
|
||||
// Validate algorithm
|
||||
validAlgos := map[string]bool{
|
||||
"HS256": true, "HS384": true, "HS512": true,
|
||||
"RS256": true, "RS384": true, "RS512": true,
|
||||
"ES256": true, "ES384": true, "ES512": true,
|
||||
}
|
||||
if !validAlgos[jwtCfg.Algorithm] {
|
||||
return fmt.Errorf("unsupported JWT algorithm: %s. Supported: HS256, HS384, HS512, RS256, RS384, RS512, ES256, ES384, ES512", jwtCfg.Algorithm)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
-221
@@ -1,221 +0,0 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseLdapServer(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
server string
|
||||
wantScheme string
|
||||
wantHost string
|
||||
wantPort int
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "ldap with host and port",
|
||||
server: "ldap://localhost:389",
|
||||
wantScheme: "ldap",
|
||||
wantHost: "localhost",
|
||||
wantPort: 389,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "ldaps with host and port",
|
||||
server: "ldaps://ldap.example.com:636",
|
||||
wantScheme: "ldaps",
|
||||
wantHost: "ldap.example.com",
|
||||
wantPort: 636,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "ldap with trailing slash",
|
||||
server: "ldap://host:389/",
|
||||
wantScheme: "ldap",
|
||||
wantHost: "host",
|
||||
wantPort: 389,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "ldap port not provided defaults to 389",
|
||||
server: "ldap://localhost",
|
||||
wantScheme: "ldap",
|
||||
wantHost: "localhost",
|
||||
wantPort: 389,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "ldaps port not provided defaults to 636",
|
||||
server: "ldaps://ldap.example.com",
|
||||
wantScheme: "ldaps",
|
||||
wantHost: "ldap.example.com",
|
||||
wantPort: 636,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "port provided but invalid returns error",
|
||||
server: "ldap://host:bad",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "port provided but invalid (ldaps) returns error",
|
||||
server: "ldaps://host:xyz",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "port zero returns error",
|
||||
server: "ldap://host:0",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "empty server",
|
||||
server: "",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "no scheme",
|
||||
server: "localhost:389",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid scheme",
|
||||
server: "http://localhost:389",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid scheme ftp",
|
||||
server: "ftp://localhost:389",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
gotScheme, gotHost, gotPort, err := parseLdapServer(tt.server)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("parseLdapServer() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if !tt.wantErr {
|
||||
if gotScheme != tt.wantScheme {
|
||||
t.Errorf("parseLdapServer() scheme = %v, want %v", gotScheme, tt.wantScheme)
|
||||
}
|
||||
if gotHost != tt.wantHost {
|
||||
t.Errorf("parseLdapServer() host = %v, want %v", gotHost, tt.wantHost)
|
||||
}
|
||||
if gotPort != tt.wantPort {
|
||||
t.Errorf("parseLdapServer() port = %v, want %v", gotPort, tt.wantPort)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyLdapConnection_ValidationOnly(t *testing.T) {
|
||||
// Save and restore global config so we don't affect other tests
|
||||
saved := Config.Auth.Methods.LdapAuth
|
||||
defer func() { Config.Auth.Methods.LdapAuth = saved }()
|
||||
|
||||
t.Run("empty server returns error", func(t *testing.T) {
|
||||
Config.Auth.Methods.LdapAuth = LdapConfig{
|
||||
AuthCommon: AuthCommon{Enabled: true},
|
||||
Server: "",
|
||||
BaseDN: "dc=test,dc=local",
|
||||
}
|
||||
err := verifyLdapConnection()
|
||||
if err == nil {
|
||||
t.Fatal("verifyLdapConnection() expected error for empty server")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "LDAP server is required") {
|
||||
t.Errorf("verifyLdapConnection() error = %v, want message containing 'LDAP server is required'", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty baseDN returns error", func(t *testing.T) {
|
||||
Config.Auth.Methods.LdapAuth = LdapConfig{
|
||||
AuthCommon: AuthCommon{Enabled: true},
|
||||
Server: "ldap://localhost:389",
|
||||
Scheme: "ldap",
|
||||
Host: "localhost",
|
||||
Port: 389,
|
||||
BaseDN: "",
|
||||
}
|
||||
err := verifyLdapConnection()
|
||||
if err == nil {
|
||||
t.Fatal("verifyLdapConnection() expected error for empty baseDN")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "baseDN is required") {
|
||||
t.Errorf("verifyLdapConnection() error = %v, want message containing 'baseDN is required'", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestValidateLdapAuth_ConfigValidation(t *testing.T) {
|
||||
saved := Config.Auth.Methods.LdapAuth
|
||||
defer func() { Config.Auth.Methods.LdapAuth = saved }()
|
||||
|
||||
t.Run("empty server returns error", func(t *testing.T) {
|
||||
Config.Auth.Methods.LdapAuth = LdapConfig{
|
||||
AuthCommon: AuthCommon{Enabled: true},
|
||||
Server: "",
|
||||
BaseDN: "dc=test,dc=local",
|
||||
UserDN: "cn=admin,dc=test,dc=local",
|
||||
}
|
||||
err := ValidateLdapAuth()
|
||||
if err == nil {
|
||||
t.Fatal("ValidateLdapAuth() expected error for empty server")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "server is required") {
|
||||
t.Errorf("ValidateLdapAuth() error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid server URL returns error", func(t *testing.T) {
|
||||
Config.Auth.Methods.LdapAuth = LdapConfig{
|
||||
AuthCommon: AuthCommon{Enabled: true},
|
||||
Server: "not-a-valid-ldap-url",
|
||||
BaseDN: "dc=test,dc=local",
|
||||
UserDN: "cn=admin,dc=test,dc=local",
|
||||
}
|
||||
err := ValidateLdapAuth()
|
||||
if err == nil {
|
||||
t.Fatal("ValidateLdapAuth() expected error for invalid server URL")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid") {
|
||||
t.Errorf("ValidateLdapAuth() error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty baseDN returns error", func(t *testing.T) {
|
||||
Config.Auth.Methods.LdapAuth = LdapConfig{
|
||||
AuthCommon: AuthCommon{Enabled: true},
|
||||
Server: "ldap://localhost:389",
|
||||
BaseDN: "",
|
||||
UserDN: "cn=admin,dc=test,dc=local",
|
||||
}
|
||||
err := ValidateLdapAuth()
|
||||
if err == nil {
|
||||
t.Fatal("ValidateLdapAuth() expected error for empty baseDN")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "baseDN is required") {
|
||||
t.Errorf("ValidateLdapAuth() error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty userDN returns error", func(t *testing.T) {
|
||||
Config.Auth.Methods.LdapAuth = LdapConfig{
|
||||
AuthCommon: AuthCommon{Enabled: true},
|
||||
Server: "ldap://localhost:389",
|
||||
BaseDN: "dc=test,dc=local",
|
||||
UserDN: "",
|
||||
}
|
||||
err := ValidateLdapAuth()
|
||||
if err == nil {
|
||||
t.Fatal("ValidateLdapAuth() expected error for empty userDN")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "userDN") {
|
||||
t.Errorf("ValidateLdapAuth() error = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
-27
@@ -1,27 +0,0 @@
|
||||
package settings
|
||||
|
||||
func CanConvertImage(ext string) bool {
|
||||
if !MediaEnabled() {
|
||||
return false
|
||||
}
|
||||
val := Config.Integrations.Media.Convert.ImagePreview[ImagePreviewType(ext)]
|
||||
if val == nil {
|
||||
return false // Extension not in the configured list
|
||||
}
|
||||
return *val
|
||||
}
|
||||
|
||||
func CanConvertVideo(ext string) bool {
|
||||
if !MediaEnabled() {
|
||||
return false
|
||||
}
|
||||
val := Config.Integrations.Media.Convert.VideoPreview[VideoPreviewType(ext)]
|
||||
if val == nil {
|
||||
return false // Extension not in the configured list
|
||||
}
|
||||
return *val
|
||||
}
|
||||
|
||||
func MediaEnabled() bool {
|
||||
return Env.FFmpegPath != "" && Env.FFprobePath != ""
|
||||
}
|
||||
-149
@@ -1,149 +0,0 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
)
|
||||
|
||||
func TestInitialize(t *testing.T) {
|
||||
type args struct {
|
||||
configFile string
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
}{
|
||||
// TODO: Add test cases.
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
Initialize(tt.args.configFile)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_setDefaults(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
want Settings
|
||||
}{
|
||||
// TODO: Add test cases.
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := SetDefaults(true); !reflect.DeepEqual(got, tt.want) {
|
||||
t.Errorf("setDefaults() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigLoadChanged(t *testing.T) {
|
||||
// Create isolated test directory
|
||||
testDir := t.TempDir()
|
||||
validContent, err := os.ReadFile("./validConfig.yaml")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read validConfig.yaml: %v", err)
|
||||
}
|
||||
configFile := filepath.Join(testDir, "config.yaml")
|
||||
if err = os.WriteFile(configFile, validContent, 0644); err != nil {
|
||||
t.Fatalf("failed to write test config: %v", err)
|
||||
}
|
||||
|
||||
defaultConfig := SetDefaults(true)
|
||||
err = loadConfigWithDefaults(configFile, true)
|
||||
if err != nil {
|
||||
t.Fatalf("error loading config file: %v", err)
|
||||
}
|
||||
// Use go-cmp to compare the two structs
|
||||
if diff := cmp.Diff(defaultConfig, Config); diff == "" {
|
||||
t.Errorf("No change when there should have been (-want +got):\n%s", diff)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigLoadEnvVars(t *testing.T) {
|
||||
// Create isolated test directory
|
||||
testDir := t.TempDir()
|
||||
validContent, err := os.ReadFile("./validConfig.yaml")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read validConfig.yaml: %v", err)
|
||||
}
|
||||
configFile := filepath.Join(testDir, "config.yaml")
|
||||
if err = os.WriteFile(configFile, validContent, 0644); err != nil {
|
||||
t.Fatalf("failed to write test config: %v", err)
|
||||
}
|
||||
|
||||
defaultConfig := SetDefaults(true)
|
||||
expectedKey := "MYKEY"
|
||||
// mock environment variables
|
||||
os.Setenv("FILEBROWSER_ONLYOFFICE_SECRET", expectedKey)
|
||||
err = loadConfigWithDefaults(configFile, true)
|
||||
if err != nil {
|
||||
t.Fatalf("error loading config file: %v", err)
|
||||
}
|
||||
if Config.Integrations.OnlyOffice.Secret != expectedKey {
|
||||
t.Errorf("Expected OnlyOffice.Secret to be '%v', got '%s'", expectedKey, Config.Integrations.OnlyOffice.Secret)
|
||||
}
|
||||
// Use go-cmp to compare the two structs
|
||||
if diff := cmp.Diff(defaultConfig, Config); diff == "" {
|
||||
t.Errorf("No change when there should have been (-want +got):\n%s", diff)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigLoadSpecificValues(t *testing.T) {
|
||||
// Create isolated test directory
|
||||
testDir := t.TempDir()
|
||||
validContent, err := os.ReadFile("./validConfig.yaml")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read validConfig.yaml: %v", err)
|
||||
}
|
||||
configFile := filepath.Join(testDir, "config.yaml")
|
||||
if err = os.WriteFile(configFile, validContent, 0644); err != nil {
|
||||
t.Fatalf("failed to write test config: %v", err)
|
||||
}
|
||||
|
||||
defaultConfig := SetDefaults(true)
|
||||
err = loadConfigWithDefaults(configFile, true)
|
||||
if err != nil {
|
||||
t.Fatalf("error loading config file: %v", err)
|
||||
}
|
||||
testCases := []struct {
|
||||
fieldName string
|
||||
globalVal interface{}
|
||||
newVal interface{}
|
||||
}{
|
||||
{"Server.Database", Config.Server.Database, defaultConfig.Server.Database},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
if tc.globalVal == tc.newVal {
|
||||
t.Errorf("Differences should have been found:\nConfig.%s: %v \nSetConfig: %v \n", tc.fieldName, tc.globalVal, tc.newVal)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidConfig(t *testing.T) {
|
||||
// Create isolated test directory
|
||||
testDir := t.TempDir()
|
||||
invalidContent, err := os.ReadFile("./invalidConfig.yaml")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read invalidConfig.yaml: %v", err)
|
||||
}
|
||||
configFile := filepath.Join(testDir, "config.yaml")
|
||||
if err = os.WriteFile(configFile, invalidContent, 0644); err != nil {
|
||||
t.Fatalf("failed to write test config: %v", err)
|
||||
}
|
||||
|
||||
err = loadConfigWithDefaults(configFile, true)
|
||||
// Config loads successfully but validation should catch missing sources
|
||||
if err == nil {
|
||||
err = ValidateConfig(Config)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error for config with missing required sources, got nil")
|
||||
}
|
||||
}
|
||||
}
|
||||
-60
@@ -1,60 +0,0 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/users"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
|
||||
// InitializeUserResolvers sets up the global resolvers and config providers in the users package
|
||||
// This should be called once during application initialization
|
||||
func InitializeUserResolvers() {
|
||||
// Set up the source name resolver
|
||||
users.SetSourceNameResolver(func(sourceName string) (string, error) {
|
||||
source, ok := Config.Server.NameToSource[sourceName]
|
||||
if !ok {
|
||||
logger.Debug("Could not get scope from source name: ", sourceName)
|
||||
return "", nil
|
||||
}
|
||||
return source.Path, nil
|
||||
})
|
||||
|
||||
// Set up the source config provider
|
||||
users.SetSourceConfig(&users.SourceConfigProvider{
|
||||
GetSourceByPath: func(path string) (users.SourceInfo, bool) {
|
||||
source, ok := Config.Server.SourceMap[path]
|
||||
if !ok {
|
||||
return users.SourceInfo{}, false
|
||||
}
|
||||
return users.SourceInfo{
|
||||
Path: source.Path,
|
||||
Name: source.Name,
|
||||
DefaultUserScope: source.Config.DefaultUserScope,
|
||||
}, true
|
||||
},
|
||||
GetSourceByName: func(name string) (users.SourceInfo, bool) {
|
||||
source, ok := Config.Server.NameToSource[name]
|
||||
if !ok {
|
||||
return users.SourceInfo{}, false
|
||||
}
|
||||
return users.SourceInfo{
|
||||
Path: source.Path,
|
||||
Name: source.Name,
|
||||
DefaultUserScope: source.Config.DefaultUserScope,
|
||||
}, true
|
||||
},
|
||||
GetAllSources: func() []users.SourceInfo {
|
||||
sources := make([]users.SourceInfo, 0, len(Config.Server.Sources))
|
||||
for _, source := range Config.Server.Sources {
|
||||
sources = append(sources, users.SourceInfo{
|
||||
Path: source.Path,
|
||||
Name: source.Name,
|
||||
DefaultUserScope: source.Config.DefaultUserScope,
|
||||
})
|
||||
}
|
||||
return sources
|
||||
},
|
||||
GetDefaultScopes: func() []users.SourceScope {
|
||||
return Config.UserDefaults.DefaultScopes
|
||||
},
|
||||
})
|
||||
}
|
||||
-1283
File diff suppressed because it is too large.
Load diff
-236
@@ -1,236 +0,0 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGenerateConfigYaml_Basic(t *testing.T) {
|
||||
reNumber := regexp.MustCompile(`^-?\d+(\.\d+)?$`)
|
||||
// Test using the actual source directory structure
|
||||
trueVal := true
|
||||
config := &Settings{
|
||||
UserDefaults: UserDefaults{
|
||||
Locale: "es", // Non-default value
|
||||
DarkMode: &trueVal, // Default value
|
||||
DisableOfficePreviewExt: ".docx .xlsx", // This field is deprecated
|
||||
},
|
||||
Auth: Auth{
|
||||
Key: "secret123", // This is a secret field
|
||||
AdminUsername: "testadmin", // This is a secret field
|
||||
AdminPassword: "testpassword", // This is a secret field
|
||||
},
|
||||
}
|
||||
|
||||
// Test different combinations
|
||||
testCases := []struct {
|
||||
name string
|
||||
showComments bool
|
||||
showFull bool
|
||||
filterDeprecated bool
|
||||
expectSecrets bool
|
||||
expectDeprecated bool
|
||||
}{
|
||||
{
|
||||
name: "API_mode_all_fields",
|
||||
showComments: false,
|
||||
showFull: true,
|
||||
filterDeprecated: false,
|
||||
expectSecrets: true, // Secrets should be hidden
|
||||
expectDeprecated: true, // Deprecated should be shown
|
||||
},
|
||||
{
|
||||
name: "Static_config_filter_deprecated",
|
||||
showComments: true,
|
||||
showFull: true,
|
||||
filterDeprecated: true,
|
||||
expectSecrets: true, // Secrets should be hidden
|
||||
expectDeprecated: false, // Deprecated should be filtered
|
||||
},
|
||||
{
|
||||
name: "Filtered_non_defaults",
|
||||
showComments: false,
|
||||
showFull: false,
|
||||
filterDeprecated: false,
|
||||
expectSecrets: true, // Secrets should be hidden
|
||||
expectDeprecated: false, // Might not appear if matches default
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// Use the current directory structure for real testing
|
||||
yamlOutput, err := GenerateConfigYaml(config, tc.showComments, tc.showFull, tc.filterDeprecated)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateConfigYaml failed: %v", err)
|
||||
}
|
||||
|
||||
// Basic validation - should produce some output
|
||||
if len(yamlOutput) == 0 {
|
||||
t.Fatal("Generated empty YAML")
|
||||
}
|
||||
|
||||
// Test string quoting - string values should be quoted, numbers and booleans should not
|
||||
lines := strings.Split(yamlOutput, "\n")
|
||||
for _, line := range lines {
|
||||
if strings.Contains(line, ":") && !strings.HasSuffix(strings.TrimSpace(line), ":") {
|
||||
// This is a key-value line, skip comment portion
|
||||
parts := strings.SplitN(line, ":", 2)
|
||||
if len(parts) == 2 {
|
||||
value := strings.TrimSpace(parts[1])
|
||||
// Remove comments
|
||||
if commentIdx := strings.Index(value, "#"); commentIdx >= 0 {
|
||||
value = strings.TrimSpace(value[:commentIdx])
|
||||
}
|
||||
|
||||
// Skip empty values, arrays, and objects
|
||||
if value == "" || strings.HasPrefix(value, "[") || strings.HasPrefix(value, "{") {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if it's a number (should NOT be quoted)
|
||||
if reNumber.MatchString(value) {
|
||||
if strings.HasPrefix(value, "\"") {
|
||||
t.Errorf("Numeric value should not be quoted in line: %s (value: '%s')", line, value)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if it's a boolean (should NOT be quoted)
|
||||
if value == "true" || value == "false" {
|
||||
if strings.HasPrefix(value, "\"") {
|
||||
t.Errorf("Boolean value should not be quoted in line: %s (value: '%s')", line, value)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if it's null (should NOT be quoted - it's a YAML literal for nil)
|
||||
if value == "null" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Everything else should be a quoted string
|
||||
if !strings.HasPrefix(value, "\"") || !strings.HasSuffix(value, "\"") {
|
||||
// Add debug info to see what's wrong
|
||||
t.Logf("DEBUG: Checking value '%s' from line '%s'", value, strings.TrimSpace(line))
|
||||
t.Errorf("String value should be quoted in line: %s (value: %s)", strings.TrimSpace(line), value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Test secret hiding
|
||||
if tc.expectSecrets {
|
||||
if !strings.Contains(yamlOutput, "**hidden**") {
|
||||
t.Error("Expected secrets to be hidden with **hidden**")
|
||||
}
|
||||
if strings.Contains(yamlOutput, "secret123") || strings.Contains(yamlOutput, "testadmin") || strings.Contains(yamlOutput, "testpassword") {
|
||||
t.Error("Secret values should not appear in output")
|
||||
}
|
||||
}
|
||||
|
||||
// Test deprecated field filtering
|
||||
hasDeprecated := strings.Contains(yamlOutput, "disableOfficePreviewExt")
|
||||
if tc.expectDeprecated && !hasDeprecated {
|
||||
t.Error("Expected deprecated field to be present")
|
||||
}
|
||||
if !tc.expectDeprecated && hasDeprecated && tc.filterDeprecated {
|
||||
t.Error("Expected deprecated field to be filtered out when filterDeprecated=true")
|
||||
}
|
||||
|
||||
// Test comments
|
||||
hasComments := strings.Contains(yamlOutput, "#")
|
||||
if tc.showComments && !hasComments {
|
||||
t.Error("Expected comments to be present when showComments=true")
|
||||
}
|
||||
|
||||
t.Logf("Test case: %s\nGenerated YAML:\n%s\n", tc.name, yamlOutput)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectCommentsAndSecrets_Basic(t *testing.T) {
|
||||
// Test the comment and secret collection directly
|
||||
comments, secrets, deprecated, err := CollectCommentsAndSecrets(".")
|
||||
if err != nil {
|
||||
t.Fatalf("CollectCommentsAndSecrets failed: %v", err)
|
||||
}
|
||||
|
||||
// Should find some secrets in the actual source files
|
||||
foundSecrets := false
|
||||
for typeName, typeSecrets := range secrets {
|
||||
for fieldName := range typeSecrets {
|
||||
t.Logf("Found secret: %s.%s", typeName, fieldName)
|
||||
foundSecrets = true
|
||||
}
|
||||
}
|
||||
if !foundSecrets {
|
||||
t.Error("Expected to find some secret fields in the actual source code")
|
||||
}
|
||||
|
||||
// Should find some deprecated fields
|
||||
foundDeprecated := false
|
||||
for typeName, typeDeprecated := range deprecated {
|
||||
for fieldName := range typeDeprecated {
|
||||
t.Logf("Found deprecated: %s.%s", typeName, fieldName)
|
||||
foundDeprecated = true
|
||||
}
|
||||
}
|
||||
if !foundDeprecated {
|
||||
t.Error("Expected to find some deprecated fields in the actual source code")
|
||||
}
|
||||
|
||||
t.Logf("Comments: %d types", len(comments))
|
||||
t.Logf("Secrets: %d types", len(secrets))
|
||||
t.Logf("Deprecated: %d types", len(deprecated))
|
||||
}
|
||||
|
||||
func TestGenerateYaml_StaticGeneration(t *testing.T) {
|
||||
// Test the static generation function that's used by FILEBROWSER_GENERATE_CONFIG=true
|
||||
|
||||
// Create a temporary config for testing
|
||||
config := &Settings{
|
||||
UserDefaults: UserDefaults{
|
||||
Locale: "en",
|
||||
DisableOfficePreviewExt: ".docx .xlsx", // This should be filtered out
|
||||
},
|
||||
Auth: Auth{
|
||||
Key: "test-secret", // This should be redacted
|
||||
},
|
||||
}
|
||||
|
||||
// Test static generation with comments and deprecated filtering
|
||||
yamlOutput, err := GenerateConfigYamlWithSource(config, true, true, true, ".")
|
||||
if err != nil {
|
||||
t.Fatalf("Static generation failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify comments are included
|
||||
if !strings.Contains(yamlOutput, "#") {
|
||||
t.Error("Static generation should include comments")
|
||||
}
|
||||
|
||||
// Verify deprecated field is filtered out
|
||||
if strings.Contains(yamlOutput, "disableOfficePreviewExt") {
|
||||
t.Error("Static generation should filter out deprecated field disableOfficePreviewExt")
|
||||
}
|
||||
|
||||
// Verify secrets are redacted
|
||||
if !strings.Contains(yamlOutput, "**hidden**") {
|
||||
t.Error("Static generation should redact secrets")
|
||||
}
|
||||
if strings.Contains(yamlOutput, "test-secret") {
|
||||
t.Error("Static generation should not contain actual secret values")
|
||||
}
|
||||
|
||||
// Verify it has proper structure
|
||||
if !strings.Contains(yamlOutput, "userDefaults:") {
|
||||
t.Error("Static generation should contain userDefaults section")
|
||||
}
|
||||
if !strings.Contains(yamlOutput, "auth:") {
|
||||
t.Error("Static generation should contain auth section")
|
||||
}
|
||||
|
||||
t.Logf("Static generation successful with comments and proper filtering")
|
||||
}
|
||||
-477
@@ -1,477 +0,0 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Test structs for comprehensive testing
|
||||
type TestConfig struct {
|
||||
PublicField string `json:"publicField"` // A regular public field
|
||||
SecretField string `json:"secretField"` // secret: this should be hidden
|
||||
DeprecatedField string `json:"deprecatedField"` // deprecated: this field is old
|
||||
EmptyField string `json:"emptyField"` // An empty string field
|
||||
DefaultField string `json:"defaultField"` // A field that matches default
|
||||
NonDefaultField string `json:"nonDefaultField"` // A field that differs from default
|
||||
NestedStruct TestNested `json:"nestedStruct"`
|
||||
}
|
||||
|
||||
type TestNested struct {
|
||||
NestedPublic string `json:"nestedPublic"` // A nested public field
|
||||
NestedSecret string `json:"nestedSecret"` // secret: nested secret field
|
||||
NestedDeprecated string `json:"nestedDeprecated"` // deprecated: nested deprecated field
|
||||
}
|
||||
|
||||
// Helper to create test source files for comment parsing
|
||||
func createTestSourceFile(t *testing.T, content string) (string, func()) {
|
||||
tmpFile, err := os.CreateTemp("", "test_*.go")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := tmpFile.WriteString(content); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tmpFile.Close()
|
||||
|
||||
cleanup := func() {
|
||||
os.Remove(tmpFile.Name())
|
||||
}
|
||||
|
||||
return tmpFile.Name(), cleanup
|
||||
}
|
||||
|
||||
func TestGenerateConfigYaml_StringQuoting(t *testing.T) {
|
||||
reNumber := regexp.MustCompile(`^-?\d+(\.\d+)?$`)
|
||||
// Create a simple test source file for this test
|
||||
sourceContent := `package settings
|
||||
|
||||
type Settings struct {
|
||||
UserDefaults UserDefaults ` + "`json:\"userDefaults\"`" + `
|
||||
Auth Auth ` + "`json:\"auth\"`" + `
|
||||
}
|
||||
|
||||
type UserDefaults struct {
|
||||
Locale string ` + "`json:\"locale\"`" + `
|
||||
}
|
||||
|
||||
type Auth struct {
|
||||
AdminUsername string ` + "`json:\"adminUsername\"`" + `
|
||||
}
|
||||
`
|
||||
tmpFile, cleanup := createTestSourceFile(t, sourceContent)
|
||||
defer cleanup()
|
||||
sourcePath := tmpFile[:strings.LastIndex(tmpFile, "/")]
|
||||
|
||||
// Create real Settings with string values to test quoting
|
||||
settings := &Settings{
|
||||
UserDefaults: UserDefaults{
|
||||
Locale: "en-US",
|
||||
},
|
||||
Auth: Auth{
|
||||
AdminUsername: "admin",
|
||||
},
|
||||
}
|
||||
|
||||
// Test with no filtering - should quote all strings
|
||||
yamlOutput, err := GenerateConfigYamlWithSource(settings, false, true, false, sourcePath)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateConfigYaml failed: %v", err)
|
||||
}
|
||||
|
||||
// All string values should be quoted
|
||||
lines := strings.Split(yamlOutput, "\n")
|
||||
for _, line := range lines {
|
||||
if strings.Contains(line, ":") && !strings.HasSuffix(strings.TrimSpace(line), ":") {
|
||||
// This is a key-value line
|
||||
parts := strings.SplitN(line, ":", 2)
|
||||
if len(parts) == 2 {
|
||||
value := strings.TrimSpace(parts[1])
|
||||
|
||||
// Remove comments
|
||||
if commentIdx := strings.Index(value, "#"); commentIdx >= 0 {
|
||||
value = strings.TrimSpace(value[:commentIdx])
|
||||
}
|
||||
|
||||
// Skip empty values, arrays, objects, booleans
|
||||
if value == "" || value == "false" || value == "true" || strings.HasPrefix(value, "[") || strings.HasPrefix(value, "{") {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if it's a number (should NOT be quoted)
|
||||
if reNumber.MatchString(value) {
|
||||
if strings.HasPrefix(value, "\"") {
|
||||
t.Errorf("Numeric value should not be quoted: %s (value: '%s')", line, value)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if it's null (should NOT be quoted - it's a YAML literal for nil)
|
||||
if value == "null" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Everything else should be a quoted string
|
||||
if !strings.HasPrefix(value, "\"") || !strings.HasSuffix(value, "\"") {
|
||||
t.Errorf("String value should be quoted: %s (value: '%s')", line, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateConfigYaml_SecretHiding(t *testing.T) {
|
||||
// Create temporary source file with our test structs
|
||||
sourceContent := `package settings
|
||||
|
||||
type TestConfig struct {
|
||||
PublicField string ` + "`json:\"publicField\"`" + ` // A regular public field
|
||||
SecretField string ` + "`json:\"secretField\"`" + ` // secret: this should be hidden
|
||||
}
|
||||
|
||||
type TestNested struct {
|
||||
NestedPublic string ` + "`json:\"nestedPublic\"`" + ` // A nested public field
|
||||
NestedSecret string ` + "`json:\"nestedSecret\"`" + ` // secret: nested secret field
|
||||
}
|
||||
`
|
||||
|
||||
tmpFile, cleanup := createTestSourceFile(t, sourceContent)
|
||||
defer cleanup()
|
||||
|
||||
// Test secret detection
|
||||
_, secrets, _, err := CollectCommentsAndSecrets(tmpFile)
|
||||
if err != nil {
|
||||
t.Fatalf("CollectCommentsAndSecrets failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify secret fields are detected
|
||||
if !secrets["TestConfig"]["SecretField"] {
|
||||
t.Error("SecretField should be marked as secret")
|
||||
}
|
||||
if !secrets["TestNested"]["NestedSecret"] {
|
||||
t.Error("NestedSecret should be marked as secret")
|
||||
}
|
||||
if secrets["TestConfig"]["PublicField"] {
|
||||
t.Error("PublicField should not be marked as secret")
|
||||
}
|
||||
|
||||
// Test that secrets are properly collected
|
||||
if len(secrets["TestConfig"]) != 1 {
|
||||
t.Errorf("Expected 1 secret field in TestConfig, got %d", len(secrets["TestConfig"]))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateConfigYaml_DeprecatedFiltering(t *testing.T) {
|
||||
// Create temporary source file with deprecated fields
|
||||
sourceContent := `package settings
|
||||
|
||||
type TestConfig struct {
|
||||
PublicField string ` + "`json:\"publicField\"`" + ` // A regular public field
|
||||
DeprecatedField string ` + "`json:\"deprecatedField\"`" + ` // deprecated: this field is old
|
||||
}
|
||||
`
|
||||
|
||||
tmpFile, cleanup := createTestSourceFile(t, sourceContent)
|
||||
defer cleanup()
|
||||
|
||||
// Test deprecated detection
|
||||
_, _, deprecated, err := CollectCommentsAndSecrets(tmpFile)
|
||||
if err != nil {
|
||||
t.Fatalf("CollectCommentsAndSecrets failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify deprecated fields are detected
|
||||
if !deprecated["TestConfig"]["DeprecatedField"] {
|
||||
t.Error("DeprecatedField should be marked as deprecated")
|
||||
}
|
||||
if deprecated["TestConfig"]["PublicField"] {
|
||||
t.Error("PublicField should not be marked as deprecated")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateConfigYaml_FullVsFiltered(t *testing.T) {
|
||||
// Create minimal test source
|
||||
sourceContent := `package settings
|
||||
|
||||
type Settings struct {
|
||||
UserDefaults UserDefaults ` + "`json:\"userDefaults\"`" + `
|
||||
}
|
||||
|
||||
type UserDefaults struct {
|
||||
DarkMode bool ` + "`json:\"darkMode\"`" + `
|
||||
Locale string ` + "`json:\"locale\"`" + `
|
||||
}
|
||||
`
|
||||
tmpFile, cleanup := createTestSourceFile(t, sourceContent)
|
||||
defer cleanup()
|
||||
sourcePath := tmpFile[:strings.LastIndex(tmpFile, "/")]
|
||||
|
||||
// Create a config with both default and non-default values
|
||||
trueVal := true
|
||||
config := &Settings{
|
||||
UserDefaults: UserDefaults{
|
||||
DarkMode: &trueVal, // This matches default
|
||||
Locale: "es", // This differs from default ("en")
|
||||
},
|
||||
}
|
||||
|
||||
// Test full=true - should show all fields
|
||||
fullYaml, err := GenerateConfigYamlWithSource(config, false, true, false, sourcePath)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateConfigYaml with full=true failed: %v", err)
|
||||
}
|
||||
|
||||
// Test full=false - should only show non-default fields
|
||||
filteredYaml, err := GenerateConfigYamlWithSource(config, false, false, false, sourcePath)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateConfigYaml with full=false failed: %v", err)
|
||||
}
|
||||
|
||||
// Full output should be longer than filtered
|
||||
if len(fullYaml) <= len(filteredYaml) {
|
||||
t.Error("Full YAML should be longer than filtered YAML")
|
||||
}
|
||||
|
||||
// Filtered output should contain the non-default field
|
||||
if !strings.Contains(filteredYaml, "locale") {
|
||||
t.Error("Filtered YAML should contain non-default locale field")
|
||||
}
|
||||
|
||||
// Full output should contain default fields
|
||||
if !strings.Contains(fullYaml, "darkMode") {
|
||||
t.Error("Full YAML should contain darkMode field")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateConfigYaml_CommentsOnOff(t *testing.T) {
|
||||
// Use the actual settings source directory since comments work there
|
||||
config := &Settings{
|
||||
UserDefaults: UserDefaults{
|
||||
Locale: "en",
|
||||
},
|
||||
}
|
||||
|
||||
// Test with comments=true using the real source
|
||||
withComments, err := GenerateConfigYaml(config, true, true, false)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateConfigYaml with comments=true failed: %v", err)
|
||||
}
|
||||
|
||||
// Test with comments=false using the real source
|
||||
withoutComments, err := GenerateConfigYaml(config, false, true, false)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateConfigYaml with comments=false failed: %v", err)
|
||||
}
|
||||
|
||||
// Output with comments should be longer
|
||||
if len(withComments) <= len(withoutComments) {
|
||||
t.Errorf("YAML with comments (%d chars) should be longer than without comments (%d chars)", len(withComments), len(withoutComments))
|
||||
}
|
||||
|
||||
// With comments should contain '#' characters
|
||||
if !strings.Contains(withComments, "#") {
|
||||
t.Error("YAML with comments should contain '#' characters")
|
||||
}
|
||||
|
||||
// Without comments should not contain '#' characters
|
||||
if strings.Contains(withoutComments, "#") {
|
||||
t.Error("YAML without comments should not contain '#' characters")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateConfigYaml_IntegrationTest(t *testing.T) {
|
||||
reNumber := regexp.MustCompile(`^-?\d+(\.\d+)?$`)
|
||||
// Comprehensive test with all features
|
||||
trueVal := true
|
||||
config := &Settings{
|
||||
UserDefaults: UserDefaults{
|
||||
Locale: "es", // Non-default
|
||||
DarkMode: &trueVal, // Default
|
||||
DisableOfficePreviewExt: ".docx .xlsx", // This is deprecated
|
||||
},
|
||||
Auth: Auth{
|
||||
Key: "secret123", // This is secret
|
||||
AdminUsername: "admin", // This is secret
|
||||
AdminPassword: "password", // This is secret
|
||||
},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
showComments bool
|
||||
showFull bool
|
||||
filterDeprecated bool
|
||||
expectSecret bool
|
||||
expectDeprecated bool
|
||||
expectFull bool
|
||||
expectComments bool
|
||||
}{
|
||||
{
|
||||
name: "API mode: show all including deprecated",
|
||||
showComments: false,
|
||||
showFull: true,
|
||||
filterDeprecated: false,
|
||||
expectSecret: true, // secrets should be hidden
|
||||
expectDeprecated: true, // deprecated should be shown
|
||||
expectFull: true, // all fields shown
|
||||
expectComments: false, // no comments
|
||||
},
|
||||
{
|
||||
name: "Static config mode: filter deprecated",
|
||||
showComments: true,
|
||||
showFull: true,
|
||||
filterDeprecated: true,
|
||||
expectSecret: true, // secrets should be hidden
|
||||
expectDeprecated: false, // deprecated should be filtered
|
||||
expectFull: true, // all non-deprecated fields shown
|
||||
expectComments: true, // comments included
|
||||
},
|
||||
{
|
||||
name: "Filtered output: only changes",
|
||||
showComments: false,
|
||||
showFull: false,
|
||||
filterDeprecated: false,
|
||||
expectSecret: true, // secrets should be hidden
|
||||
expectDeprecated: false, // might not appear if matches default
|
||||
expectFull: false, // only non-default fields
|
||||
expectComments: false, // no comments
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
yamlOutput, err := GenerateConfigYaml(config, tt.showComments, tt.showFull, tt.filterDeprecated)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateConfigYaml failed: %v", err)
|
||||
}
|
||||
|
||||
// Check secret hiding
|
||||
if tt.expectSecret {
|
||||
if !strings.Contains(yamlOutput, "**hidden**") {
|
||||
t.Error("Expected secrets to be hidden with **hidden**")
|
||||
}
|
||||
if strings.Contains(yamlOutput, "secret123") {
|
||||
t.Error("Secret values should not appear in output")
|
||||
}
|
||||
}
|
||||
|
||||
// Check deprecated field filtering
|
||||
hasDeprecated := strings.Contains(yamlOutput, "disableOfficePreviewExt")
|
||||
if tt.expectDeprecated && !hasDeprecated {
|
||||
t.Error("Expected deprecated field to be present")
|
||||
}
|
||||
if !tt.expectDeprecated && hasDeprecated && tt.filterDeprecated {
|
||||
t.Error("Expected deprecated field to be filtered out")
|
||||
}
|
||||
|
||||
// Check comments
|
||||
hasComments := strings.Contains(yamlOutput, "#")
|
||||
if tt.expectComments && !hasComments {
|
||||
t.Error("Expected comments to be present")
|
||||
}
|
||||
if !tt.expectComments && hasComments {
|
||||
t.Error("Expected no comments")
|
||||
}
|
||||
|
||||
// Check string quoting - all string values should be quoted
|
||||
lines := strings.Split(yamlOutput, "\n")
|
||||
for _, line := range lines {
|
||||
if strings.Contains(line, ":") && !strings.HasSuffix(strings.TrimSpace(line), ":") {
|
||||
// This is a key-value line
|
||||
parts := strings.SplitN(line, ":", 2)
|
||||
if len(parts) == 2 {
|
||||
value := strings.TrimSpace(parts[1])
|
||||
// Skip comments and empty values
|
||||
if commentIdx := strings.Index(value, "#"); commentIdx >= 0 {
|
||||
// If comment starts at the beginning, there's no value, just a comment
|
||||
if commentIdx == 0 {
|
||||
continue
|
||||
}
|
||||
value = strings.TrimSpace(value[:commentIdx])
|
||||
}
|
||||
// Check if it's a number (should NOT be quoted)
|
||||
if reNumber.MatchString(value) {
|
||||
if strings.HasPrefix(value, "\"") {
|
||||
t.Errorf("Numeric value should not be quoted in line: %s (value: '%s')", line, value)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if it's a boolean (should NOT be quoted)
|
||||
if value == "true" || value == "false" {
|
||||
if strings.HasPrefix(value, "\"") {
|
||||
t.Errorf("Boolean value should not be quoted in line: %s (value: '%s')", line, value)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip arrays, objects, empty values, and null
|
||||
if value == "" || value == "null" || strings.HasPrefix(value, "[") || strings.HasPrefix(value, "{") || strings.Contains(value, ":") {
|
||||
continue
|
||||
}
|
||||
|
||||
// Everything else should be a quoted string
|
||||
if !strings.HasPrefix(value, "\"") || !strings.HasSuffix(value, "\"") {
|
||||
t.Errorf("String value should be quoted in line: %s (value: '%s')", line, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
t.Logf("Generated YAML for %s:\n%s", tt.name, yamlOutput)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateConfigYaml_EdgeCases(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
config *Settings
|
||||
desc string
|
||||
}{
|
||||
{
|
||||
name: "empty_config",
|
||||
config: &Settings{},
|
||||
desc: "Empty config should generate valid YAML",
|
||||
},
|
||||
{
|
||||
name: "only_defaults",
|
||||
config: &Settings{
|
||||
UserDefaults: UserDefaults{
|
||||
DarkMode: boolPtr(true), // default value
|
||||
Locale: "en", // default value
|
||||
},
|
||||
},
|
||||
desc: "Config with only default values",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Test all combinations
|
||||
for _, showComments := range []bool{false, true} {
|
||||
for _, showFull := range []bool{false, true} {
|
||||
for _, filterDeprecated := range []bool{false, true} {
|
||||
yamlOutput, err := GenerateConfigYaml(tt.config, showComments, showFull, filterDeprecated)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateConfigYaml failed for %s (comments=%v, full=%v, filterDeprecated=%v): %v",
|
||||
tt.desc, showComments, showFull, filterDeprecated, err)
|
||||
}
|
||||
|
||||
// Basic validation - should produce valid YAML structure
|
||||
if yamlOutput == "" {
|
||||
t.Errorf("Generated empty YAML for %s", tt.desc)
|
||||
}
|
||||
|
||||
// Should not contain obvious error messages (but allow "error" in log levels etc)
|
||||
if strings.Contains(yamlOutput, "Error:") || strings.Contains(yamlOutput, "ERROR:") {
|
||||
t.Errorf("YAML contains error for %s", tt.desc)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
-47
@@ -1,47 +0,0 @@
|
||||
server:
|
||||
indexingInterval: 5
|
||||
numImageProcessors: 4
|
||||
socket: ""
|
||||
tlsKey: ""
|
||||
tlsCert: ""
|
||||
enableThumbnails: false
|
||||
resizePreview: true
|
||||
port: 80
|
||||
baseURL: "/"
|
||||
database: "mydb.db"
|
||||
root: "."
|
||||
auth:
|
||||
recaptcha:
|
||||
host: ""
|
||||
key: ""
|
||||
secret: ""
|
||||
header: ""
|
||||
command: ""
|
||||
signup: false
|
||||
shell: ""
|
||||
frontend:
|
||||
name: ""
|
||||
disableExternal: true
|
||||
disableUsedPercentage: true
|
||||
files: ""
|
||||
color: ""
|
||||
userDefaults:
|
||||
scope: ""
|
||||
locale: ""
|
||||
viewMode: ""
|
||||
darkMode: true
|
||||
disableSettings: false
|
||||
singleClick: true
|
||||
sorting:
|
||||
by: ""
|
||||
asc: true
|
||||
permissions:
|
||||
admin: true
|
||||
execute: true
|
||||
create: true
|
||||
rename: true
|
||||
modify: true
|
||||
delete: true
|
||||
share: true
|
||||
download: true
|
||||
dateFormat: false
|
||||
-221
@@ -1,221 +0,0 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/users"
|
||||
)
|
||||
|
||||
// TestMigrateUserDefaults_OldToNew verifies that deprecated fields are correctly migrated to new fields
|
||||
func TestMigrateUserDefaults_OldToNew(t *testing.T) {
|
||||
// Setup: Create UserDefaults with old fields set
|
||||
Config.UserDefaults = UserDefaults{
|
||||
// Old deprecated fields
|
||||
DarkMode: boolPtr(true),
|
||||
DisableQuickToggles: true,
|
||||
HideSidebarFileActions: true,
|
||||
StickySidebar: true,
|
||||
SingleClick: true,
|
||||
HideFilesInTree: true,
|
||||
ShowToolsInSidebar: boolPtr(false),
|
||||
DeleteWithoutConfirming: true,
|
||||
DateFormat: true,
|
||||
ShowHidden: true,
|
||||
QuickDownload: true,
|
||||
ShowSelectMultiple: true,
|
||||
HideFileExt: ".tmp",
|
||||
ShowCopyPath: true,
|
||||
DeleteAfterArchive: true,
|
||||
DisablePreviewExt: ".exe",
|
||||
EditorQuickSave: true,
|
||||
DisableViewingExt: ".bin",
|
||||
DisableOnlyOfficeExt: ".md",
|
||||
PreferEditorForMarkdown: true,
|
||||
DebugOffice: true,
|
||||
DisableSearchOptions: true,
|
||||
ThemeColor: "var(--red)",
|
||||
CustomTheme: "dark",
|
||||
Locale: "de",
|
||||
LockPassword: true,
|
||||
DisableSettings: true,
|
||||
LoginMethod: "oidc",
|
||||
DisableUpdateNotifications: true,
|
||||
Permissions: UserDefaultsPermissions{
|
||||
Api: true,
|
||||
Admin: true,
|
||||
Modify: true,
|
||||
Share: true,
|
||||
Realtime: true,
|
||||
Delete: true,
|
||||
Create: true,
|
||||
Download: boolPtr(false),
|
||||
},
|
||||
Preview: UserDefaultsPreview{
|
||||
Image: boolPtr(true),
|
||||
Video: boolPtr(true),
|
||||
Audio: boolPtr(true),
|
||||
MotionVideoPreview: boolPtr(true),
|
||||
Office: boolPtr(true),
|
||||
PopUp: boolPtr(true),
|
||||
HighQuality: boolPtr(true),
|
||||
Folder: boolPtr(true),
|
||||
Models: boolPtr(true),
|
||||
// Deprecated fields that should be migrated
|
||||
DisableHideSidebar: true,
|
||||
AutoplayMedia: true,
|
||||
DefaultMediaPlayer: true,
|
||||
},
|
||||
FileLoading: users.FileLoading{
|
||||
MaxConcurrent: 5,
|
||||
UploadChunkSize: 20,
|
||||
},
|
||||
}
|
||||
|
||||
// Execute: Run migration
|
||||
migrateUserDefaults()
|
||||
|
||||
// Verify: Check that new fields are populated from old fields
|
||||
ud := &Config.UserDefaults
|
||||
|
||||
// Sidebar fields
|
||||
if !ud.Sidebar.DisableQuickToggles {
|
||||
t.Error("sidebar.disableQuickToggles should be true")
|
||||
}
|
||||
if !ud.Sidebar.HideFileActions {
|
||||
t.Error("sidebar.hideFileActions should be true")
|
||||
}
|
||||
if !ud.Sidebar.Sticky {
|
||||
t.Error("sidebar.sticky should be true")
|
||||
}
|
||||
if !ud.Sidebar.HideFiles {
|
||||
t.Error("sidebar.hideFiles should be true")
|
||||
}
|
||||
if ud.Sidebar.ShowTools == nil || *ud.Sidebar.ShowTools != false {
|
||||
t.Error("sidebar.showTools should be false")
|
||||
}
|
||||
if !ud.Sidebar.DisableHideOnPreview {
|
||||
t.Error("sidebar.disableHideOnPreview should be true")
|
||||
}
|
||||
|
||||
// Listing fields
|
||||
if !ud.Listing.DeleteWithoutConfirming {
|
||||
t.Error("listing.deleteWithoutConfirming should be true")
|
||||
}
|
||||
if !ud.Listing.DateFormat {
|
||||
t.Error("listing.dateFormat should be true")
|
||||
}
|
||||
if !ud.Listing.ShowHidden {
|
||||
t.Error("listing.showHidden should be true")
|
||||
}
|
||||
if !ud.Listing.QuickDownload {
|
||||
t.Error("listing.quickDownload should be true")
|
||||
}
|
||||
if !ud.Listing.ShowSelectMultiple {
|
||||
t.Error("listing.showSelectMultiple should be true")
|
||||
}
|
||||
if !ud.Listing.SingleClick {
|
||||
t.Error("listing.singleClick should be true")
|
||||
}
|
||||
if ud.Listing.HideFileExt != ".tmp" {
|
||||
t.Errorf("listing.hideFileExt should be '.tmp', got '%s'", ud.Listing.HideFileExt)
|
||||
}
|
||||
if !ud.Listing.ShowCopyPath {
|
||||
t.Error("listing.showCopyPath should be true")
|
||||
}
|
||||
if !ud.Listing.DeleteAfterArchive {
|
||||
t.Error("listing.deleteAfterArchive should be true")
|
||||
}
|
||||
|
||||
// Preview fields
|
||||
if ud.Preview.Image == nil || *ud.Preview.Image != true {
|
||||
t.Error("preview.image should be true")
|
||||
}
|
||||
if ud.Preview.Video == nil || *ud.Preview.Video != true {
|
||||
t.Error("preview.video should be true")
|
||||
}
|
||||
if ud.Preview.Audio == nil || *ud.Preview.Audio != true {
|
||||
t.Error("preview.audio should be true")
|
||||
}
|
||||
if ud.Preview.DisablePreviewExt != ".exe" {
|
||||
t.Errorf("preview.disablePreviewExt should be '.exe', got '%s'", ud.Preview.DisablePreviewExt)
|
||||
}
|
||||
|
||||
// FileViewer fields
|
||||
if !ud.FileViewer.EditorQuickSave {
|
||||
t.Error("fileViewer.editorQuickSave should be true")
|
||||
}
|
||||
if ud.FileViewer.AutoplayMedia == nil || *ud.FileViewer.AutoplayMedia != true {
|
||||
t.Error("fileViewer.autoplayMedia should be true")
|
||||
}
|
||||
if ud.FileViewer.DisableViewingExt != ".bin" {
|
||||
t.Errorf("fileViewer.disableViewingExt should be '.bin', got '%s'", ud.FileViewer.DisableViewingExt)
|
||||
}
|
||||
if ud.FileViewer.DisableOnlyOfficeExt != ".md" {
|
||||
t.Errorf("fileViewer.disableOnlyOfficeExt should be '.md', got '%s'", ud.FileViewer.DisableOnlyOfficeExt)
|
||||
}
|
||||
if !ud.FileViewer.PreferEditorForMarkdown {
|
||||
t.Error("fileViewer.preferEditorForMarkdown should be true")
|
||||
}
|
||||
if !ud.FileViewer.DebugOffice {
|
||||
t.Error("fileViewer.debugOffice should be true")
|
||||
}
|
||||
|
||||
// Search fields
|
||||
if !ud.Search.DisableOptions {
|
||||
t.Error("search.disableOptions should be true")
|
||||
}
|
||||
|
||||
// UI fields
|
||||
if ud.UI.DarkMode == nil || *ud.UI.DarkMode != true {
|
||||
t.Error("ui.darkMode should be true")
|
||||
}
|
||||
if ud.UI.ThemeColor != "var(--red)" {
|
||||
t.Errorf("ui.themeColor should be 'var(--red)', got '%s'", ud.UI.ThemeColor)
|
||||
}
|
||||
if ud.UI.CustomTheme != "dark" {
|
||||
t.Errorf("ui.customTheme should be 'dark', got '%s'", ud.UI.CustomTheme)
|
||||
}
|
||||
if ud.UI.Locale != "de" {
|
||||
t.Errorf("ui.locale should be 'de', got '%s'", ud.UI.Locale)
|
||||
}
|
||||
|
||||
// Account fields
|
||||
if !ud.Account.LockPassword {
|
||||
t.Error("account.lockPassword should be true")
|
||||
}
|
||||
if !ud.Account.DisableSettings {
|
||||
t.Error("account.disableSettings should be true")
|
||||
}
|
||||
if ud.Account.LoginMethod != "oidc" {
|
||||
t.Errorf("account.loginMethod should be 'oidc', got '%s'", ud.Account.LoginMethod)
|
||||
}
|
||||
if !ud.Account.DisableUpdateNotifications {
|
||||
t.Error("account.disableUpdateNotifications should be true")
|
||||
}
|
||||
|
||||
// Account Permissions
|
||||
if !ud.Account.Permissions.Api {
|
||||
t.Error("account.permissions.api should be true")
|
||||
}
|
||||
if !ud.Account.Permissions.Admin {
|
||||
t.Error("account.permissions.admin should be true")
|
||||
}
|
||||
if !ud.Account.Permissions.Modify {
|
||||
t.Error("account.permissions.modify should be true")
|
||||
}
|
||||
if !ud.Account.Permissions.Share {
|
||||
t.Error("account.permissions.share should be true")
|
||||
}
|
||||
if !ud.Account.Permissions.Realtime {
|
||||
t.Error("account.permissions.realtime should be true")
|
||||
}
|
||||
if !ud.Account.Permissions.Delete {
|
||||
t.Error("account.permissions.delete should be true")
|
||||
}
|
||||
if !ud.Account.Permissions.Create {
|
||||
t.Error("account.permissions.create should be true")
|
||||
}
|
||||
if ud.Account.Permissions.Download == nil || *ud.Account.Permissions.Download != false {
|
||||
t.Error("account.permissions.download should be false")
|
||||
}
|
||||
}
|
||||
-233
@@ -1,233 +0,0 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMultiConfigLoad(t *testing.T) {
|
||||
// Setup test directory
|
||||
testDir := t.TempDir()
|
||||
|
||||
// Create anchors file that will be referenced
|
||||
anchorsConfig := `
|
||||
# Simple anchors for testing
|
||||
simple_sources: &simple_sources
|
||||
- path: "/srv/files"
|
||||
name: "Files"
|
||||
- path: "/srv/media"
|
||||
name: "Media"
|
||||
|
||||
simple_logging: &simple_logging
|
||||
- levels: "info|warning|error"
|
||||
output: "stdout"
|
||||
|
||||
simple_auth: &simple_auth
|
||||
methods:
|
||||
password:
|
||||
enabled: true
|
||||
minLength: 8
|
||||
`
|
||||
// Use pattern-based naming: main config is "config.yaml", so anchors must be "*-config.yaml"
|
||||
anchorsPath := filepath.Join(testDir, "definitions-config.yaml")
|
||||
if err := os.WriteFile(anchorsPath, []byte(anchorsConfig), 0644); err != nil {
|
||||
t.Fatalf("Failed to write anchors config: %v", err)
|
||||
}
|
||||
|
||||
// Create main config file that references the anchors
|
||||
mainConfig := `
|
||||
server:
|
||||
port: 9000
|
||||
baseURL: "/test/"
|
||||
minSearchLength: 2
|
||||
sources: *simple_sources
|
||||
logging: *simple_logging
|
||||
|
||||
auth:
|
||||
tokenExpirationHours: 3
|
||||
<<: *simple_auth
|
||||
|
||||
frontend:
|
||||
name: "Test FileBrowser"
|
||||
|
||||
userDefaults:
|
||||
darkMode: true
|
||||
viewMode: "grid"
|
||||
locale: "en"
|
||||
`
|
||||
mainPath := filepath.Join(testDir, "config.yaml")
|
||||
if err := os.WriteFile(mainPath, []byte(mainConfig), 0644); err != nil {
|
||||
t.Fatalf("Failed to write main config: %v", err)
|
||||
}
|
||||
|
||||
// Test loading the multi-config setup
|
||||
// Use generate=true to skip filesystem validation of fake paths
|
||||
err := loadConfigWithDefaults(mainPath, true)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to load multi-config: %v", err)
|
||||
}
|
||||
|
||||
// Verify that values from different files were loaded correctly
|
||||
if Config.Server.Port != 9000 {
|
||||
t.Errorf("Expected server port 9000, got %d", Config.Server.Port)
|
||||
}
|
||||
|
||||
if Config.Server.BaseURL != "/test/" {
|
||||
t.Errorf("Expected baseURL '/test/', got '%s'", Config.Server.BaseURL)
|
||||
}
|
||||
|
||||
if Config.Server.MinSearchLength != 2 {
|
||||
t.Errorf("Expected minSearchLength 2, got %d", Config.Server.MinSearchLength)
|
||||
}
|
||||
|
||||
if Config.Auth.TokenExpirationHours != 3 {
|
||||
t.Errorf("Expected token expiration 3 hours, got %d", Config.Auth.TokenExpirationHours)
|
||||
}
|
||||
|
||||
if !Config.Auth.Methods.PasswordAuth.Enabled {
|
||||
t.Error("Expected password auth to be enabled")
|
||||
}
|
||||
|
||||
if Config.Auth.Methods.PasswordAuth.MinLength != 8 {
|
||||
t.Errorf("Expected password min length 8, got %d", Config.Auth.Methods.PasswordAuth.MinLength)
|
||||
}
|
||||
|
||||
if len(Config.Server.Sources) != 2 {
|
||||
t.Errorf("Expected 2 sources, got %d", len(Config.Server.Sources))
|
||||
}
|
||||
|
||||
if Config.Frontend.Name != "Test FileBrowser" {
|
||||
t.Errorf("Expected frontend name 'Test FileBrowser', got '%s'", Config.Frontend.Name)
|
||||
}
|
||||
|
||||
if Config.UserDefaults.ViewMode != "grid" {
|
||||
t.Errorf("Expected view mode 'grid', got '%s'", Config.UserDefaults.ViewMode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultiConfigFallback(t *testing.T) {
|
||||
// Setup test directory
|
||||
testDir := t.TempDir()
|
||||
|
||||
// Create a simple config file (no anchors)
|
||||
simpleConfig := `
|
||||
server:
|
||||
port: 8080
|
||||
baseURL: "/"
|
||||
sources:
|
||||
- path: "/srv"
|
||||
|
||||
auth:
|
||||
tokenExpirationHours: 2
|
||||
|
||||
frontend:
|
||||
name: "Simple FileBrowser"
|
||||
`
|
||||
configPath := filepath.Join(testDir, "simple-config.yaml")
|
||||
if err := os.WriteFile(configPath, []byte(simpleConfig), 0644); err != nil {
|
||||
t.Fatalf("Failed to write simple config: %v", err)
|
||||
}
|
||||
|
||||
// Test that fallback works for simple configs
|
||||
// Use generate=true to skip filesystem validation
|
||||
err := loadConfigWithDefaults(configPath, true)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to load simple config: %v", err)
|
||||
}
|
||||
|
||||
// Verify values were loaded correctly
|
||||
if Config.Server.Port != 8080 {
|
||||
t.Errorf("Expected server port 8080, got %d", Config.Server.Port)
|
||||
}
|
||||
|
||||
if Config.Frontend.Name != "Simple FileBrowser" {
|
||||
t.Errorf("Expected frontend name 'Simple FileBrowser', got '%s'", Config.Frontend.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultiConfigWithNestedReferences(t *testing.T) {
|
||||
// Note: Logger output may appear during tests - this is normal
|
||||
|
||||
// Setup test directory
|
||||
testDir := t.TempDir()
|
||||
|
||||
// Create anchors file with nested structure
|
||||
anchorsConfig := `
|
||||
# Base logging configuration
|
||||
base_logging: &base_logging
|
||||
- levels: "info|warning|error"
|
||||
output: "stdout"
|
||||
|
||||
# Base permissions
|
||||
base_permissions: &base_permissions
|
||||
modify: false
|
||||
share: false
|
||||
admin: false
|
||||
api: false
|
||||
`
|
||||
// Use pattern-based naming: main config is "config.yaml", so anchors must be "*-config.yaml"
|
||||
anchorsPath := filepath.Join(testDir, "definitions-config.yaml")
|
||||
if err := os.WriteFile(anchorsPath, []byte(anchorsConfig), 0644); err != nil {
|
||||
t.Fatalf("Failed to write anchors config: %v", err)
|
||||
}
|
||||
|
||||
// Create main config that uses references
|
||||
mainConfig := `
|
||||
server:
|
||||
port: 8080
|
||||
sources:
|
||||
- path: "/srv"
|
||||
logging: *base_logging
|
||||
|
||||
auth:
|
||||
tokenExpirationHours: 2
|
||||
|
||||
userDefaults:
|
||||
darkMode: true
|
||||
locale: "en"
|
||||
viewMode: "normal"
|
||||
permissions: *base_permissions
|
||||
|
||||
frontend:
|
||||
name: "Nested Reference Test"
|
||||
`
|
||||
mainPath := filepath.Join(testDir, "config.yaml")
|
||||
if err := os.WriteFile(mainPath, []byte(mainConfig), 0644); err != nil {
|
||||
t.Fatalf("Failed to write main config: %v", err)
|
||||
}
|
||||
|
||||
// Test loading the nested reference setup
|
||||
// Use generate=true to skip filesystem validation
|
||||
err := loadConfigWithDefaults(mainPath, true)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to load nested config: %v", err)
|
||||
}
|
||||
|
||||
// Verify nested references work
|
||||
if len(Config.Server.Logging) == 0 {
|
||||
t.Error("Expected logging configuration to be loaded")
|
||||
}
|
||||
|
||||
if Config.Server.Logging[0].Output != "stdout" {
|
||||
t.Errorf("Expected logging output 'stdout', got '%s'", Config.Server.Logging[0].Output)
|
||||
}
|
||||
|
||||
// Verify user defaults
|
||||
if Config.UserDefaults.DarkMode == nil || !*Config.UserDefaults.DarkMode {
|
||||
t.Errorf("Expected dark mode true, got %v", Config.UserDefaults.DarkMode)
|
||||
}
|
||||
|
||||
if Config.UserDefaults.Locale != "en" {
|
||||
t.Errorf("Expected locale 'en', got '%s'", Config.UserDefaults.Locale)
|
||||
}
|
||||
|
||||
// Verify permissions were loaded from anchor
|
||||
if Config.UserDefaults.Permissions.Modify != false {
|
||||
t.Errorf("Expected modify permission false (from base), got %v", Config.UserDefaults.Permissions.Modify)
|
||||
}
|
||||
|
||||
if Config.UserDefaults.Permissions.Share != false {
|
||||
t.Errorf("Expected share permission false (from base), got %v", Config.UserDefaults.Permissions.Share)
|
||||
}
|
||||
}
|
||||
-169
@@ -1,169 +0,0 @@
|
||||
//go:generate go run ./tools/yaml.go -input=common/settings/settings.go -output=config.generated.yaml
|
||||
package settings
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/users"
|
||||
)
|
||||
|
||||
// boolPtr returns a pointer to a bool value
|
||||
func boolPtr(b bool) *bool {
|
||||
return &b
|
||||
}
|
||||
|
||||
// boolValueOrDefault returns the value of a bool pointer, or the default if nil
|
||||
func boolValueOrDefault(ptr *bool, defaultValue bool) bool {
|
||||
if ptr != nil {
|
||||
return *ptr
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
// ConvertPermissionsToUsers converts UserDefaultsPermissions to users.Permissions
|
||||
func ConvertPermissionsToUsers(p UserDefaultsAccountPermissions) users.Permissions {
|
||||
return users.Permissions{
|
||||
Api: p.Api,
|
||||
Admin: p.Admin,
|
||||
Modify: p.Modify,
|
||||
Share: p.Share,
|
||||
Realtime: p.Realtime,
|
||||
Delete: p.Delete,
|
||||
Create: p.Create,
|
||||
Download: boolValueOrDefault(p.Download, true),
|
||||
}
|
||||
}
|
||||
|
||||
const DefaultUsersHomeBasePath = "/users"
|
||||
|
||||
// AuthMethod describes an authentication method.
|
||||
type AuthMethod string
|
||||
|
||||
// GenerateKey generates a key of 512 bits.
|
||||
func GenerateKey() ([]byte, error) {
|
||||
b := make([]byte, 64)
|
||||
_, err := rand.Read(b)
|
||||
// Note that err == nil only if we read len(b) bytes.
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return b, nil
|
||||
}
|
||||
|
||||
func GetSettingsConfig(nameType string, Value string) string {
|
||||
return nameType + Value
|
||||
}
|
||||
|
||||
func AdminPerms() users.Permissions {
|
||||
return users.Permissions{
|
||||
Modify: true,
|
||||
Share: true,
|
||||
Admin: true,
|
||||
Api: true,
|
||||
Download: true,
|
||||
Delete: true,
|
||||
Create: true,
|
||||
Realtime: false,
|
||||
}
|
||||
}
|
||||
|
||||
// Apply applies the default options to a user.
|
||||
// Keep this in sync with [UserDefaults]: every user-facing field there should be copied
|
||||
// here (except DefaultScopes, which is wired through source config rather than this helper).
|
||||
func ApplyUserDefaults(u *users.User) {
|
||||
d := Config.UserDefaults
|
||||
|
||||
// Account settings
|
||||
u.DisableSettings = d.Account.DisableSettings
|
||||
u.LockPassword = d.Account.LockPassword
|
||||
u.DisableUpdateNotifications = d.Account.DisableUpdateNotifications
|
||||
|
||||
// Sidebar settings
|
||||
u.DisableQuickToggles = d.Sidebar.DisableQuickToggles
|
||||
u.HideSidebarFileActions = d.Sidebar.HideFileActions
|
||||
u.StickySidebar = d.Sidebar.Sticky
|
||||
u.HideFilesInTree = d.Sidebar.HideFiles
|
||||
u.ShowToolsInSidebar = boolValueOrDefault(d.Sidebar.ShowTools, true)
|
||||
|
||||
// Listing settings
|
||||
u.DeleteWithoutConfirming = d.Listing.DeleteWithoutConfirming
|
||||
u.DateFormat = d.Listing.DateFormat
|
||||
u.ShowHidden = d.Listing.ShowHidden
|
||||
u.QuickDownload = d.Listing.QuickDownload
|
||||
u.ShowSelectMultiple = d.Listing.ShowSelectMultiple
|
||||
u.SingleClick = d.Listing.SingleClick
|
||||
u.HideFileExt = d.Listing.HideFileExt
|
||||
u.ShowCopyPath = d.Listing.ShowCopyPath
|
||||
u.DeleteAfterArchive = d.Listing.DeleteAfterArchive
|
||||
u.ViewMode = d.Listing.ViewMode
|
||||
u.GallerySize = d.Listing.GallerySize
|
||||
|
||||
// Preview settings
|
||||
u.Preview.DisableHideSidebar = d.Sidebar.DisableHideOnPreview
|
||||
u.Preview.Image = boolValueOrDefault(d.Preview.Image, true)
|
||||
u.Preview.Video = boolValueOrDefault(d.Preview.Video, true)
|
||||
u.Preview.Audio = boolValueOrDefault(d.Preview.Audio, true)
|
||||
u.Preview.MotionVideoPreview = boolValueOrDefault(d.Preview.MotionVideoPreview, true)
|
||||
u.Preview.Office = boolValueOrDefault(d.Preview.Office, true)
|
||||
u.Preview.PopUp = boolValueOrDefault(d.Preview.PopUp, true)
|
||||
u.Preview.Folder = boolValueOrDefault(d.Preview.Folder, true)
|
||||
u.Preview.Models = boolValueOrDefault(d.Preview.Models, true)
|
||||
u.DisablePreviewExt = d.Preview.DisablePreviewExt
|
||||
|
||||
// FileViewer settings
|
||||
u.EditorQuickSave = d.FileViewer.EditorQuickSave
|
||||
u.Preview.AutoplayMedia = boolValueOrDefault(d.FileViewer.AutoplayMedia, true)
|
||||
u.Preview.DefaultMediaPlayer = d.FileViewer.DefaultMediaPlayer
|
||||
u.DisableViewingExt = d.FileViewer.DisableViewingExt
|
||||
u.DisableOnlyOfficeExt = d.FileViewer.DisableOnlyOfficeExt
|
||||
u.DisableOfficePreviewExt = d.FileViewer.DisableOnlyOfficeExt // deprecated field, map to same source
|
||||
u.PreferEditorForMarkdown = d.FileViewer.PreferEditorForMarkdown
|
||||
u.DebugOffice = d.FileViewer.DebugOffice
|
||||
|
||||
// Search settings
|
||||
u.DisableSearchOptions = d.Search.DisableOptions
|
||||
|
||||
// UI settings
|
||||
u.DarkMode = boolValueOrDefault(d.UI.DarkMode, true)
|
||||
u.ThemeColor = d.UI.ThemeColor
|
||||
u.CustomTheme = d.UI.CustomTheme
|
||||
u.Locale = d.UI.Locale
|
||||
|
||||
// FileLoading settings
|
||||
u.FileLoading = d.FileLoading
|
||||
|
||||
// Permissions
|
||||
u.Permissions.Api = d.Account.Permissions.Api
|
||||
u.Permissions.Admin = d.Account.Permissions.Admin
|
||||
u.Permissions.Modify = d.Account.Permissions.Modify
|
||||
u.Permissions.Share = d.Account.Permissions.Share
|
||||
u.Permissions.Realtime = d.Account.Permissions.Realtime
|
||||
u.Permissions.Delete = d.Account.Permissions.Delete
|
||||
u.Permissions.Create = d.Account.Permissions.Create
|
||||
u.Permissions.Download = boolValueOrDefault(d.Account.Permissions.Download, true)
|
||||
|
||||
if u.LoginMethod == "" && d.Account.LoginMethod != "" {
|
||||
u.LoginMethod = users.LoginMethod(d.Account.LoginMethod)
|
||||
}
|
||||
|
||||
if len(u.Scopes) == 0 && u.Username != "anonymous" {
|
||||
for _, source := range Config.Server.Sources {
|
||||
if source.Config.DefaultEnabled {
|
||||
u.Scopes = append(u.Scopes, users.SourceScope{
|
||||
Name: source.Path, // backend name is path
|
||||
Scope: source.Config.DefaultUserScope,
|
||||
})
|
||||
u.SidebarLinks = append(u.SidebarLinks, users.SidebarLink{
|
||||
Name: source.Name,
|
||||
Category: "source",
|
||||
Target: "/",
|
||||
Icon: "",
|
||||
SourceName: source.Path,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
u.Version = users.CurrentUserMigrationVersion
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
|
||||
)
|
||||
|
||||
// StorageBackend is a settings storage backend.
|
||||
type StorageBackend interface {
|
||||
Get() (*Settings, error)
|
||||
Save(*Settings) error
|
||||
GetServer() (*Server, error)
|
||||
SaveServer(*Server) error
|
||||
}
|
||||
|
||||
// Storage is a settings storage.
|
||||
type Storage struct {
|
||||
back StorageBackend
|
||||
}
|
||||
|
||||
// NewStorage creates a settings storage from a backend.
|
||||
func NewStorage(back StorageBackend) *Storage {
|
||||
return &Storage{back: back}
|
||||
}
|
||||
|
||||
// Get returns the settings for the current instance.
|
||||
func (s *Storage) Get() (*Settings, error) {
|
||||
set, err := s.back.Get()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return set, nil
|
||||
}
|
||||
|
||||
// Save saves the settings for the current instance.
|
||||
func (s *Storage) Save(set *Settings) error {
|
||||
if len(set.Auth.Key) == 0 {
|
||||
return errors.ErrEmptyKey
|
||||
}
|
||||
|
||||
if set.UserDefaults.UI.Locale == "" {
|
||||
set.UserDefaults.UI.Locale = "en"
|
||||
}
|
||||
|
||||
if set.UserDefaults.Listing.ViewMode == "" {
|
||||
set.UserDefaults.Listing.ViewMode = "normal"
|
||||
}
|
||||
|
||||
err := s.back.Save(set)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetServer wraps StorageBackend.GetServer.
|
||||
func (s *Storage) GetServer() (*Server, error) {
|
||||
return s.back.GetServer()
|
||||
}
|
||||
|
||||
// SaveServer wraps StorageBackend.SaveServer and adds some verification.
|
||||
func (s *Storage) SaveServer(ser *Server) error {
|
||||
return s.back.SaveServer(ser)
|
||||
}
|
||||
@@ -1,460 +0,0 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/users"
|
||||
)
|
||||
|
||||
type AllowedMethods string
|
||||
|
||||
var Env Environment
|
||||
|
||||
const (
|
||||
ProxyAuth AllowedMethods = "proxyAuth"
|
||||
NoAuth AllowedMethods = "noAuth"
|
||||
PasswordAuth AllowedMethods = "passwordAuth"
|
||||
)
|
||||
|
||||
type Settings struct {
|
||||
Server Server `json:"server"`
|
||||
Auth Auth `json:"auth"`
|
||||
Frontend Frontend `json:"frontend"`
|
||||
UserDefaults UserDefaults `json:"userDefaults"`
|
||||
Integrations Integrations `json:"integrations"`
|
||||
}
|
||||
|
||||
type Environment struct {
|
||||
IsPlaywright bool `json:"-"`
|
||||
IsDevMode bool `json:"-"`
|
||||
IsFirstLoad bool `json:"-"` // used internally to track if this is the first load of the application
|
||||
MuPdfAvailable bool `json:"-"` // used internally if compiled with mupdf support
|
||||
EmbeddedFs bool `json:"-"` // used internally if compiled with embedded fs support
|
||||
FFmpegPath string `json:"-"`
|
||||
FFprobePath string `json:"-"`
|
||||
LoginIconPath string `json:"-"` // resolved login icon path (filesystem or embedded)
|
||||
LoginIconIsCustom bool `json:"-"` // true if login icon is from custom filesystem path
|
||||
LoginIconEmbeddedPath string `json:"-"` // embedded asset path for default icon
|
||||
FaviconPath string `json:"-"` // resolved favicon path (filesystem or embedded)
|
||||
FaviconIsCustom bool `json:"-"` // true if favicon is from custom filesystem path
|
||||
FaviconEmbeddedPath string `json:"-"` // embedded asset path for default favicon
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
MinSearchLength int `json:"minSearchLength" yaml:"minSearchLength"` // minimum length of search query to begin searching (default: 3)
|
||||
DisableUpdateCheck bool `json:"disableUpdateCheck"` // disables backend update check service
|
||||
NumImageProcessors int `json:"numImageProcessors"` // number of concurrent image processing jobs used to create previews, default is number of cpu cores available.
|
||||
Socket string `json:"socket"` // socket to listen on
|
||||
TLSKey string `json:"tlsKey"` // path to TLS key
|
||||
TLSCert string `json:"tlsCert"` // path to TLS cert
|
||||
DisablePreviews bool `json:"disablePreviews"` // disable all previews thumbnails, simple icons will be used
|
||||
DisableResize bool `json:"disablePreviewResize"` // disable resizing of previews for faster loading over slow connections
|
||||
DisableTypeDetectionByHeader bool `json:"disableTypeDetectionByHeader"` // disable type detection by header, useful if filesystem is slow.
|
||||
Port int `json:"port"` // port to listen on
|
||||
ListenAddress string `json:"listen"` // address to listen on (default: 0.0.0.0)
|
||||
BaseURL string `json:"baseURL"` // base URL for the server, the subpath that the server is running on.
|
||||
Logging []LogConfig `json:"logging" yaml:"logging"`
|
||||
Database string `json:"database"` // path to the database file
|
||||
Sources []*Source `json:"sources" validate:"required,dive"`
|
||||
ExternalUrl string `json:"externalUrl"` // used by share links if set (eg. http://mydomain.com)
|
||||
InternalUrl string `json:"internalUrl"` // used by integrations if set, this is the base domain that an integration service will use to communicate with filebrowser (eg. http://localhost:8080)
|
||||
CacheDir string `json:"cacheDir"` // path to the cache directory, used for thumbnails and other cached files
|
||||
CacheDirCleanup bool `json:"cacheDirCleanup"` // whether to automatically cleanup the cache directory. Note: docker must also mount a persistent volume to persist the cache (default: false)
|
||||
MaxArchiveSizeGB int64 `json:"maxArchiveSize"` // maximum archive/unarchive size in GB. 0 means no limit. (default: 20)
|
||||
Filesystem Filesystem `json:"filesystem"` // filesystem settings
|
||||
IndexSqlConfig IndexSqlConfig `json:"indexSqlConfig"` // Index database SQL configuration
|
||||
DisableWebDAV bool `json:"disableWebDAV"` // disable webdav support (default: false)
|
||||
// not exposed to config
|
||||
SourceMap map[string]*Source `json:"-" validate:"omitempty"` // uses realpath as key
|
||||
NameToSource map[string]*Source `json:"-" validate:"omitempty"` // uses name as key
|
||||
}
|
||||
|
||||
type Filesystem struct {
|
||||
CreateFilePermission string `json:"createFilePermission" validate:"required,file_permission"` // Unix permissions like 644, 755, 2755 (default: 644)
|
||||
CreateDirectoryPermission string `json:"createDirectoryPermission" validate:"required,file_permission"` // Unix permissions like 755, 2755, 1777 (default: 755)
|
||||
}
|
||||
|
||||
// Index SQL startup integrity modes (IndexSqlConfig.StartupIntegrityCheck).
|
||||
const (
|
||||
// IndexStartupIntegrityQuickCheck runs PRAGMA quick_check (default). Slower on very large DBs but thorough.
|
||||
IndexStartupIntegrityQuickCheck = "quickCheck"
|
||||
// IndexStartupIntegrityProbe verifies sqlite_master and optionally one row read only; fast for large indexes.
|
||||
IndexStartupIntegrityProbe = "probe"
|
||||
// IndexStartupIntegrityOff skips startup checks beyond sql.Open Ping; least safe, fastest boot.
|
||||
IndexStartupIntegrityOff = "off"
|
||||
)
|
||||
|
||||
type IndexSqlConfig struct {
|
||||
BatchSize int `json:"batchSize"` // number of items to batch in a single transaction, typically 500-5000. higher = faster but could use more memory.
|
||||
CacheSizeMB int `json:"cacheSizeMB"` // size of the SQLite cache in MB
|
||||
WalMode bool `json:"walMode"` // enable the more complex WAL journaling mode. Slower, more memory usage, but better for deployments with constant user activity.
|
||||
DisableReuse bool `json:"disableReuse"` // enable to always create a new indexing database on startup.
|
||||
StartupIntegrityCheck string `json:"startupIntegrityCheck,omitempty" validate:"omitempty,oneof=quickCheck probe off"` // the method used to check the integrity of the index database on startup (default: quickCheck)
|
||||
}
|
||||
|
||||
type Integrations struct {
|
||||
OnlyOffice OnlyOffice `json:"office" validate:"omitempty"`
|
||||
Media Media `json:"media" validate:"omitempty"`
|
||||
}
|
||||
|
||||
// onlyoffice secret is stored in the local.json file
|
||||
// docker exec <containerID> /var/www/onlyoffice/documentserver/npm/json -f /etc/onlyoffice/documentserver/local.json 'services.CoAuthoring.secret.session.string'
|
||||
type OnlyOffice struct {
|
||||
Url string `json:"url" validate:"required"` // The URL to the OnlyOffice Document Server, needs to be accessible to the user.
|
||||
InternalUrl string `json:"internalUrl"` // An optional internal address that the filebrowser server can use to communicate with the OnlyOffice Document Server, could be useful to bypass proxy.
|
||||
Secret string `json:"secret" validate:"required"` // secret: authentication key for OnlyOffice integration
|
||||
ViewOnly bool `json:"viewOnly"` // view only mode for OnlyOffice
|
||||
}
|
||||
|
||||
type Media struct {
|
||||
FfmpegPath string `json:"ffmpegPath"` // path to ffmpeg directory with ffmpeg and ffprobe (eg. /usr/local/bin)
|
||||
Convert FfmpegConvert `json:"convert"` // config for ffmpeg conversion settings
|
||||
Debug bool `json:"debug"` // output ffmpeg stdout for media integration -- careful can produces lots of output!
|
||||
ExtractEmbeddedSubtitles bool `json:"extractEmbeddedSubtitles"` // extract embedded subtitles from media files
|
||||
ExiftoolPath string `json:"exiftoolPath"` // path to exiftool executable
|
||||
}
|
||||
|
||||
type FfmpegConvert struct {
|
||||
ImagePreview map[ImagePreviewType]*bool `json:"imagePreview"` // supported image preview formats. defaults vary by type (see individual type docs)
|
||||
VideoPreview map[VideoPreviewType]*bool `json:"videoPreview"` // supported video preview formats. defaults to true for all types unless explicitly disabled.
|
||||
}
|
||||
|
||||
type ImagePreviewType string
|
||||
|
||||
const (
|
||||
HEICImagePreview ImagePreviewType = "heic"
|
||||
JPEGImagePreview ImagePreviewType = "jpeg" // only used as fallback for JPEG formats that can't otherwise be decoded
|
||||
//RAWImagePreview ImagePreviewType = "raw"
|
||||
)
|
||||
|
||||
func (i ImagePreviewType) String() string {
|
||||
return string(i)
|
||||
}
|
||||
|
||||
// AllImagePreviewTypes contains all supported image preview types.
|
||||
var AllImagePreviewTypes = []ImagePreviewType{
|
||||
HEICImagePreview,
|
||||
JPEGImagePreview,
|
||||
//RAWImagePreview,
|
||||
}
|
||||
|
||||
type VideoPreviewType string
|
||||
|
||||
const (
|
||||
MP4VideoPreview VideoPreviewType = "mp4"
|
||||
WebMVideoPreview VideoPreviewType = "webm"
|
||||
MOVVideoPreview VideoPreviewType = "mov"
|
||||
AVIVideoPreview VideoPreviewType = "avi"
|
||||
MKVVideoPreview VideoPreviewType = "mkv"
|
||||
FLVVideoPreview VideoPreviewType = "flv"
|
||||
WMVVideoPreview VideoPreviewType = "wmv"
|
||||
M4VVideoPreview VideoPreviewType = "m4v"
|
||||
ThreeGPVideoPreview VideoPreviewType = "3gp"
|
||||
ThreeGP2VideoPreview VideoPreviewType = "3g2"
|
||||
TSVideoPreview VideoPreviewType = "ts"
|
||||
M2TSVideoPreview VideoPreviewType = "m2ts"
|
||||
VOBVideoPreview VideoPreviewType = "vob"
|
||||
ASFVideoPreview VideoPreviewType = "asf"
|
||||
MPGVideoPreview VideoPreviewType = "mpg"
|
||||
MPEGVideoPreview VideoPreviewType = "mpeg"
|
||||
F4VVideoPreview VideoPreviewType = "f4v"
|
||||
OGVVideoPreview VideoPreviewType = "ogv"
|
||||
)
|
||||
|
||||
func (v VideoPreviewType) String() string {
|
||||
return string(v)
|
||||
}
|
||||
|
||||
// AllVideoPreviewTypes contains all supported video preview types.
|
||||
var AllVideoPreviewTypes = []VideoPreviewType{
|
||||
MP4VideoPreview,
|
||||
WebMVideoPreview,
|
||||
MOVVideoPreview,
|
||||
AVIVideoPreview,
|
||||
MKVVideoPreview,
|
||||
FLVVideoPreview,
|
||||
WMVVideoPreview,
|
||||
M4VVideoPreview,
|
||||
ThreeGPVideoPreview,
|
||||
ThreeGP2VideoPreview,
|
||||
TSVideoPreview,
|
||||
M2TSVideoPreview,
|
||||
VOBVideoPreview,
|
||||
ASFVideoPreview,
|
||||
MPGVideoPreview,
|
||||
MPEGVideoPreview,
|
||||
F4VVideoPreview,
|
||||
OGVVideoPreview,
|
||||
}
|
||||
|
||||
type LogConfig struct {
|
||||
Levels string `json:"levels" yaml:"levels"` // separated list of log levels to enable. (eg. "info|warning|error|debug")
|
||||
ApiLevels string `json:"apiLevels" yaml:"apiLevels"` // separated list of log levels to enable for the API. (eg. "info|warning|error")
|
||||
Output string `json:"output" yaml:"output"` // output location. (eg. "stdout" or "path/to/file.log")
|
||||
NoColors bool `json:"noColors" yaml:"noColors"` // disable colors in the output
|
||||
Json bool `json:"json" yaml:"json"` // output in json format
|
||||
Utc bool `json:"utc" yaml:"utc"` // use UTC time in the output instead of local time.
|
||||
ApiFilter string `json:"apiFilter" yaml:"apiFilter"` // regex filter that excludes matching full api paths from being logged. (eg. '/user\?id\=self') Defaults to '^/health|^/favicon.ico|^/static|^/public/static'
|
||||
}
|
||||
type Source struct {
|
||||
Path string `json:"path" validate:"required"` // file system path. (Can be relative)
|
||||
Name string `json:"name"` // display name
|
||||
Config SourceConfig `json:"config,omitempty"`
|
||||
}
|
||||
|
||||
type SourceConfig struct {
|
||||
DenyByDefault bool `json:"denyByDefault,omitempty"` // deny access unless an "allow" access rule was specifically created.
|
||||
Private bool `json:"private"` // designate as source as private -- currently just means no sharing permitted.
|
||||
Disabled bool `json:"disabled,omitempty"` // disable the source, this is useful so you don't need to remove it from the config file
|
||||
IndexingInterval uint32 `json:"indexingIntervalMinutes,omitempty"` // deprecated: create a rule with indexingIntervalMinutes to set the indexing interval for this source
|
||||
Conditionals ConditionalFilter `json:"conditionals"` // deprecated: use source.rules instead
|
||||
Rules []ConditionalRule `json:"rules"` // list of item rules to apply to specific paths
|
||||
DefaultUserScope string `json:"defaultUserScope"` // defaults to root of index "/" should match folders under path
|
||||
DefaultEnabled bool `json:"defaultEnabled"` // should be added as a default source for new users?
|
||||
CreateUserDir bool `json:"createUserDir"` // create a user directory for each user under defaultUserScope + username
|
||||
UseLogicalSize bool `json:"useLogicalSize"` // calculate sizes based on logical size instead of disk utilization (du -sh), folders will be 0 bytes when empty.
|
||||
// hidden but used internally - optimized map lookups for conditional rules
|
||||
ResolvedRules ResolvedRulesConfig `json:"-"`
|
||||
}
|
||||
|
||||
type ConditionalFilter struct {
|
||||
Hidden bool `json:"hidden"` // deprecated: use ignoreHidden instead. eg, FolderPath: "/" and ignoreHidden: true will exclude hidden files and folders under the root folder.
|
||||
IgnoreHidden bool `json:"ignoreHidden"` // deprecated: use ignoreHidden instead. eg, FolderPath: "/" and ignoreHidden: true will exclude hidden files and folders under the root folder.
|
||||
ZeroSizeFolders bool `json:"ignoreZeroSizeFolders"` // deprecated: use ignoreZeroSizeFolders instead. eg, FolderPath: "/" and ignoreZeroSizeFolders: true will ignore folders with 0 size under the root folder.
|
||||
ItemRules []ConditionalRule `json:"rules"` // list of item rules to apply to specific paths
|
||||
}
|
||||
|
||||
type ConditionalRule struct {
|
||||
NeverWatchPath string `json:"neverWatchPath"` // index the folder in the first pass to get included in search, but never re-indexed.
|
||||
IncludeRootItem string `json:"includeRootItem"` // include only these items at root folder level
|
||||
FileStartsWith string `json:"fileStartsWith"` // (global) exclude files that start with these prefixes. Eg. "archive-" or "backup-"
|
||||
FolderStartsWith string `json:"folderStartsWith"` // (global) exclude folders that start with these prefixes. Eg. "archive-" or "backup-"
|
||||
FileEndsWith string `json:"fileEndsWith"` // (global) exclude files that end with these suffixes. Eg. ".jpg" or ".txt"
|
||||
FolderEndsWith string `json:"folderEndsWith"` // (global) exclude folders that end with these suffixes. Eg. ".thumbnails" or ".git"
|
||||
FolderPath string `json:"folderPath"` // (global) exclude folders that match this path. Eg. "/path/to/folder" or "/path/to/folder/subfolder"
|
||||
FilePath string `json:"filePath"` // (global) exclude files that match this path. Eg. "/path/to/file.txt" or "/path/to/file.txt/subfile.txt"
|
||||
FileNames string `json:"fileNames"` // deprecated: exclude files that match these names. Eg. "file.txt" or "test.csv"
|
||||
FolderNames string `json:"folderNames"` // deprecated: exclude folders that match these names. Eg. "folder" or "subfolder"
|
||||
FileName string `json:"fileName"` // (global) exclude files that match these names. Eg. "file.txt" or "test.csv"
|
||||
FolderName string `json:"folderName"` // (global) exclude folders that match these names. Eg. "folder" or "subfolder"
|
||||
|
||||
Viewable bool `json:"viewable"` // Enable viewing in UI but exclude from indexing
|
||||
IgnoreHidden bool `json:"ignoreHidden"` // Excludes only hidden files and folders
|
||||
IgnoreZeroSizeFolders bool `json:"ignoreZeroSizeFolders"` // Excludes only folders with 0 size
|
||||
IgnoreSymlinks bool `json:"ignoreSymlinks"` // Excludes symbolic links
|
||||
}
|
||||
|
||||
// ConditionalMaps provides O(1) lookup performance for conditional rules
|
||||
// Maps are built from ConditionalFilter during initialization
|
||||
type ResolvedRulesConfig struct {
|
||||
// Exact match maps - O(1) lookup (only for names, not StartsWith/EndsWith)
|
||||
FileNames map[string]ConditionalRule // key: file name
|
||||
FolderNames map[string]ConditionalRule // key: folder name
|
||||
|
||||
// exact match for paths
|
||||
FilePaths map[string]ConditionalRule // key: file path
|
||||
FolderPaths map[string]ConditionalRule // key: folder path
|
||||
|
||||
FileEndsWith []ConditionalRule // list of item rules that have been resolved for specific paths
|
||||
FolderEndsWith []ConditionalRule // list of item rules that have been resolved for specific paths
|
||||
FileStartsWith []ConditionalRule // list of item rules that have been resolved for specific paths
|
||||
FolderStartsWith []ConditionalRule // list of item rules that have been resolved for specific paths
|
||||
|
||||
// NeverWatch paths map - O(1) lookup for all paths with neverWatch: true
|
||||
// This replaces the old NeverWatchPaths slice
|
||||
NeverWatchPaths map[string]struct{} // key: full folder path
|
||||
IncludeRootItems map[string]struct{} // key: inclusive root item
|
||||
|
||||
IgnoreAllHidden bool // Excludes all hidden files and folders
|
||||
IgnoreAllZeroSizeFolders bool // Excludes all folders with 0 size
|
||||
IgnoreAllSymlinks bool // Excludes all symbolic links
|
||||
IndexingDisabled bool // Excludes all files and folders from indexing
|
||||
NoRules bool // No rules are configured
|
||||
}
|
||||
|
||||
type Frontend struct {
|
||||
Name string `json:"name"` // display name
|
||||
DisableDefaultLinks bool `json:"disableDefaultLinks"` // disable default links in the sidebar
|
||||
DisableUsedPercentage bool `json:"disableUsedPercentage"` // disable used percentage for the sources in the sidebar
|
||||
ExternalLinks []ExternalLink `json:"externalLinks"`
|
||||
DisableNavButtons bool `json:"disableNavButtons"` // disable the nav buttons in the sidebar
|
||||
Styling StylingConfig `json:"styling"`
|
||||
Favicon string `json:"favicon"` // path to a favicon to use for the frontend
|
||||
Description string `json:"description"` // description that shows up in html head meta description
|
||||
LoginIcon string `json:"loginIcon"` // path to an image file for the login page icon
|
||||
LoginButtonText string `json:"loginButtonText"` // text to display on the login button
|
||||
OIDCLoginButtonText string `json:"oidcLoginButtonText"` // text to display on the OIDC login button
|
||||
}
|
||||
|
||||
type StylingConfig struct {
|
||||
DisableEventBasedThemes bool `json:"disableEventThemes"` // disable the event based themes,
|
||||
CustomCSS string `json:"customCSS"` // if a valid path to a css file is provided, it will be applied for all users. (eg. "reduce-rounded-corners.css")
|
||||
CustomCSSRaw string `json:"-"` // The css raw content to use for the custom css.
|
||||
LightBackground string `json:"lightBackground"` // specify a valid CSS color property value to use as the background color in light mode
|
||||
DarkBackground string `json:"darkBackground"` // Specify a valid CSS color property value to use as the background color in dark mode
|
||||
CustomThemes map[string]CustomTheme `json:"customThemes"` // A list of custom css files that each user can select to override the default styling. if "default" is key name then it will be the default option.
|
||||
// In-memory (not exposed to config)
|
||||
CustomThemeOptions map[string]CustomTheme `json:"-"` // not exposed
|
||||
}
|
||||
|
||||
type CustomTheme struct {
|
||||
Description string `json:"description"` // The description of the theme to display in the UI.
|
||||
CSS string `json:"css,omitempty"` // The css file path and filename to use for the theme.
|
||||
CssRaw string `json:"-"` // The css raw content to use for the theme.
|
||||
}
|
||||
|
||||
type ExternalLink struct {
|
||||
Text string `json:"text" validate:"required"` // the text to display on the link
|
||||
Title string `json:"title"` // the title to display on hover
|
||||
Url string `json:"url" validate:"required"` // the url to link to
|
||||
}
|
||||
|
||||
// UserDefaultsPermissions holds permission settings with pointer types for defaults
|
||||
type UserDefaultsPermissions struct {
|
||||
Api bool `json:"api"` // deprecated: use account.permissions.api instead. allow api access
|
||||
Admin bool `json:"admin"` // deprecated: use account.permissions.admin instead. allow admin access
|
||||
Modify bool `json:"modify"` // deprecated: use account.permissions.modify instead. allow modifying files
|
||||
Share bool `json:"share"` // deprecated: use account.permissions.share instead. allow sharing files
|
||||
Realtime bool `json:"realtime"` // deprecated: use account.permissions.realtime instead. allow realtime updates
|
||||
Delete bool `json:"delete"` // deprecated: use account.permissions.delete instead. allow deleting files
|
||||
Create bool `json:"create"` // deprecated: use account.permissions.create instead. allow creating or uploading files
|
||||
Download *bool `json:"download"` // deprecated: use account.permissions.download instead. allow downloading files
|
||||
}
|
||||
|
||||
// New organized structures for UserDefaults
|
||||
|
||||
// UserDefaultsSidebar holds sidebar-related settings
|
||||
type UserDefaultsSidebar struct {
|
||||
DisableQuickToggles bool `json:"disableQuickToggles"` // disable the quick toggles in the sidebar
|
||||
HideFileActions bool `json:"hideFileActions"` // hide the file actions in the sidebar
|
||||
DisableHideOnPreview bool `json:"disableHideOnPreview"` // keep sidebar open when previewing files (was preview.disableHideSidebar)
|
||||
Sticky bool `json:"sticky"` // keep sidebar open when navigating
|
||||
HideFiles bool `json:"hideFiles"` // hide files in the sidebar tree navigation, when true, will show only directories
|
||||
ShowTools *bool `json:"showTools"` // show sidebar links with category "tool"; default is true
|
||||
}
|
||||
|
||||
// UserDefaultsListing holds file listing display settings
|
||||
type UserDefaultsListing struct {
|
||||
DeleteWithoutConfirming bool `json:"deleteWithoutConfirming"` // delete files without confirmation
|
||||
DateFormat bool `json:"dateFormat"` // when false, the date is relative, when true, the date is an exact timestamp
|
||||
ShowHidden bool `json:"showHidden"` // show hidden files in the UI. On windows this includes files starting with a dot and windows hidden files
|
||||
QuickDownload bool `json:"quickDownload"` // show icon to download in one click
|
||||
ShowSelectMultiple bool `json:"showSelectMultiple"` // show select multiple files on desktop
|
||||
SingleClick bool `json:"singleClick"` // open directory on single click, also enables middle click to open in new tab
|
||||
HideFileExt string `json:"hideFileExt"` // space separated list of file extensions to hide in UI
|
||||
ShowCopyPath bool `json:"showCopyPath"` // show copy path button in the context menu
|
||||
DeleteAfterArchive bool `json:"deleteAfterArchive"` // delete source files after successful creation/extraction of archives
|
||||
ViewMode string `json:"viewMode"` // view mode to use: eg. normal, list, grid, or compact
|
||||
GallerySize int `json:"gallerySize"` // 0-9 - the size of the gallery thumbnails
|
||||
}
|
||||
|
||||
// UserDefaultsPreview holds preview-related settings
|
||||
type UserDefaultsPreview struct {
|
||||
Image *bool `json:"image"` // show thumbnails for image files
|
||||
Video *bool `json:"video"` // show thumbnails for video files
|
||||
Audio *bool `json:"audio"` // show thumbnails for audio files
|
||||
MotionVideoPreview *bool `json:"motionVideoPreview"` // show multiple frames for videos in thumbnail preview when hovering
|
||||
Office *bool `json:"office"` // show thumbnails for office files
|
||||
PopUp *bool `json:"popup"` // show larger popup preview when hovering over thumbnail
|
||||
DisablePreviewExt string `json:"disablePreviewExt"` // comma separated list of file extensions to disable preview for
|
||||
HighQuality *bool `json:"highQuality"` // high quality preview thumbnails
|
||||
Folder *bool `json:"folder"` // show thumbnails for folders that have previewable contents
|
||||
Models *bool `json:"models"` // show live thumbnails for 3D models files
|
||||
// deprecated fields
|
||||
DisableHideSidebar bool `json:"disableHideSidebar"` // deprecated: use sidebar.disableHideOnPreview instead. disable the hide sidebar preview for previews and editors
|
||||
DefaultMediaPlayer bool `json:"defaultMediaPlayer"` // deprecated: use fileViewer.defaultMediaPlayer instead. disable the styled feature-rich media player for browser default
|
||||
AutoplayMedia bool `json:"autoplayMedia"` // deprecated: use fileViewer.autoplayMedia instead. autoplay media files in preview
|
||||
}
|
||||
|
||||
// UserDefaultsFileViewer holds file viewer/editor settings
|
||||
type UserDefaultsFileViewer struct {
|
||||
EditorQuickSave bool `json:"editorQuickSave"` // show quick save button in editor
|
||||
AutoplayMedia *bool `json:"autoplayMedia"` // autoplay media files in preview
|
||||
DisableViewingExt string `json:"disableViewingExt"` // comma separated list of file extensions to disable viewing for
|
||||
DisableOnlyOfficeExt string `json:"disableOnlyOfficeExt"` // list of file extensions to disable onlyoffice editor for
|
||||
PreferEditorForMarkdown bool `json:"preferEditorForMarkdown"` // prefer editor first for markdown files instead of the Markdown Viewer
|
||||
DebugOffice bool `json:"debugOffice"` // debug onlyoffice editor
|
||||
DefaultMediaPlayer bool `json:"defaultMediaPlayer"` // disable the styled feature-rich media player for browser default
|
||||
}
|
||||
|
||||
// UserDefaultsSearch holds search-related settings
|
||||
type UserDefaultsSearch struct {
|
||||
DisableOptions bool `json:"disableOptions"` // disable the search options in the search bar
|
||||
}
|
||||
|
||||
// UserDefaultsUI holds UI/appearance settings
|
||||
type UserDefaultsUI struct {
|
||||
DarkMode *bool `json:"darkMode"` // should dark mode be enabled
|
||||
ThemeColor string `json:"themeColor"` // theme color to use: eg. #ff0000, or var(--red), var(--purple), etc
|
||||
CustomTheme string `json:"customTheme"` // Name of theme to use chosen from custom themes config
|
||||
Locale string `json:"locale"` // language to use: eg. de, en, or fr
|
||||
|
||||
}
|
||||
|
||||
// UserDefaultsAccount holds account/security settings
|
||||
type UserDefaultsAccount struct {
|
||||
Permissions UserDefaultsAccountPermissions `json:"permissions"`
|
||||
LockPassword bool `json:"lockPassword"` // disable the user from changing their password
|
||||
DisableSettings bool `json:"disableSettings"` // disable the user from viewing the settings page
|
||||
LoginMethod string `json:"loginMethod,omitempty"` // login method to use: eg. password, proxy, oidc
|
||||
DisableUpdateNotifications bool `json:"disableUpdateNotifications"` // disable update notifications banner for admin users
|
||||
}
|
||||
|
||||
// UserDefaultsAccountPermissions holds permission settings
|
||||
type UserDefaultsAccountPermissions struct {
|
||||
Api bool `json:"api"` // allow api access
|
||||
Admin bool `json:"admin"` // allow admin access
|
||||
Modify bool `json:"modify"` // allow modifying files
|
||||
Share bool `json:"share"` // allow sharing files
|
||||
Realtime bool `json:"realtime"` // allow realtime updates
|
||||
Delete bool `json:"delete"` // allow deleting files
|
||||
Create bool `json:"create"` // allow creating or uploading files
|
||||
Download *bool `json:"download"` // allow downloading files
|
||||
}
|
||||
|
||||
// UserDefaults is a type that holds the default values for some fields on User.
|
||||
type UserDefaults struct {
|
||||
// New organized structure
|
||||
Sidebar UserDefaultsSidebar `json:"sidebar,omitempty"`
|
||||
Listing UserDefaultsListing `json:"listing,omitempty"`
|
||||
Preview UserDefaultsPreview `json:"preview,omitempty"`
|
||||
FileViewer UserDefaultsFileViewer `json:"fileViewer,omitempty"`
|
||||
Search UserDefaultsSearch `json:"search,omitempty"`
|
||||
UI UserDefaultsUI `json:"ui,omitempty"`
|
||||
FileLoading users.FileLoading `json:"fileLoading,omitempty"`
|
||||
Account UserDefaultsAccount `json:"account,omitempty"`
|
||||
|
||||
// Deprecated fields - kept for backwards compatibility
|
||||
EditorQuickSave bool `json:"editorQuickSave,omitempty"` // deprecated: use fileViewer.editorQuickSave instead
|
||||
HideSidebarFileActions bool `json:"hideSidebarFileActions,omitempty"` // deprecated: use sidebar.hideSidebarFileActions instead
|
||||
DisableQuickToggles bool `json:"disableQuickToggles,omitempty"` // deprecated: use sidebar.disableQuickToggles instead
|
||||
DisableSearchOptions bool `json:"disableSearchOptions,omitempty"` // deprecated: use search.disableOptions instead
|
||||
StickySidebar bool `json:"stickySidebar,omitempty"` // deprecated: use sidebar.stickySidebar instead
|
||||
HideFilesInTree bool `json:"hideFilesInTree,omitempty"` // deprecated: use sidebar.hideFilesInTree instead
|
||||
DarkMode *bool `json:"darkMode,omitempty"` // deprecated: use sidebar.darkMode instead
|
||||
Locale string `json:"locale,omitempty"` // deprecated: use ui.locale instead
|
||||
ViewMode string `json:"viewMode,omitempty"` // deprecated: use sidebar.viewMode instead
|
||||
SingleClick bool `json:"singleClick,omitempty"` // deprecated: use sidebar.singleClick instead
|
||||
ShowHidden bool `json:"showHidden,omitempty"` // deprecated: use listing.showHidden instead
|
||||
HideFileExt string `json:"hideFileExt,omitempty"` // deprecated: use listing.hideFileExt instead
|
||||
DateFormat bool `json:"dateFormat,omitempty"` // deprecated: use listing.dateFormat instead
|
||||
GallerySize int `json:"gallerySize,omitempty"` // deprecated: use sidebar.gallerySize instead
|
||||
ThemeColor string `json:"themeColor,omitempty"` // deprecated: use ui.themeColor instead
|
||||
QuickDownload bool `json:"quickDownload,omitempty"` // deprecated: use listing.quickDownload instead
|
||||
DisablePreviewExt string `json:"disablePreviewExt,omitempty"` // deprecated: use preview.disablePreviewExt instead
|
||||
DisableViewingExt string `json:"disableViewingExt,omitempty"` // deprecated: use fileViewer.disableViewingExt instead
|
||||
LockPassword bool `json:"lockPassword,omitempty"` // deprecated: use account.lockPassword instead
|
||||
DisableSettings bool `json:"disableSettings,omitempty"` // deprecated: use account.disableSettings instead
|
||||
DefaultScopes []users.SourceScope `json:"-"`
|
||||
Permissions UserDefaultsPermissions `json:"permissions,omitempty"` // deprecated: use account.permissions instead
|
||||
LoginMethod string `json:"loginMethod,omitempty"` // deprecated: use account.loginMethod instead
|
||||
DisableUpdateNotifications bool `json:"disableUpdateNotifications,omitempty"` // deprecated: use account.disableUpdateNotifications instead
|
||||
DeleteWithoutConfirming bool `json:"deleteWithoutConfirming,omitempty"` // deprecated: use listing.deleteWithoutConfirming instead
|
||||
DeleteAfterArchive bool `json:"deleteAfterArchive,omitempty"` // deprecated: use listing.deleteAfterArchive instead
|
||||
DisableOfficePreviewExt string `json:"disableOfficePreviewExt,omitempty"` // deprecated: use disablePreviewExt instead
|
||||
DisableOnlyOfficeExt string `json:"disableOnlyOfficeExt,omitempty"` // deprecated: use fileViewer.disableOnlyOfficeExt instead
|
||||
CustomTheme string `json:"customTheme,omitempty"` // deprecated: use ui.customTheme instead
|
||||
ShowSelectMultiple bool `json:"showSelectMultiple,omitempty"` // deprecated: use listing.showSelectMultiple instead
|
||||
ShowToolsInSidebar *bool `json:"showToolsInSidebar,omitempty"` // deprecated: use sidebar.showToolsInSidebar instead
|
||||
DebugOffice bool `json:"debugOffice,omitempty"` // deprecated: use fileViewer.debugOffice instead
|
||||
PreferEditorForMarkdown bool `json:"preferEditorForMarkdown,omitempty"` // deprecated: use fileViewer.preferEditorForMarkdown instead
|
||||
ShowCopyPath bool `json:"showCopyPath,omitempty"` // deprecated: use listing.showCopyPath instead
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
|
||||
// Strict hex color regex: #RGB, #RGBA, #RRGGBB, #RRGGBBAA
|
||||
var hexColorRE = regexp.MustCompile(`^#([0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$`)
|
||||
|
||||
// All valid CSS named colors (W3C spec, lower-case)
|
||||
var cssNamedColors = map[string]struct{}{
|
||||
"aliceblue": {}, "antiquewhite": {}, "aqua": {}, "aquamarine": {}, "azure": {},
|
||||
"beige": {}, "bisque": {}, "black": {}, "blanchedalmond": {}, "blue": {}, "blueviolet": {},
|
||||
"brown": {}, "burlywood": {}, "cadetblue": {}, "chartreuse": {}, "chocolate": {},
|
||||
"coral": {}, "cornflowerblue": {}, "cornsilk": {}, "crimson": {}, "cyan": {},
|
||||
"darkblue": {}, "darkcyan": {}, "darkgoldenrod": {}, "darkgray": {}, "darkgreen": {},
|
||||
"darkgrey": {}, "darkkhaki": {}, "darkmagenta": {}, "darkolivegreen": {}, "darkorange": {},
|
||||
"darkorchid": {}, "darkred": {}, "darksalmon": {}, "darkseagreen": {}, "darkslateblue": {},
|
||||
"darkslategray": {}, "darkslategrey": {}, "darkturquoise": {}, "darkviolet": {}, "deeppink": {},
|
||||
"deepskyblue": {}, "dimgray": {}, "dimgrey": {}, "dodgerblue": {}, "firebrick": {},
|
||||
"floralwhite": {}, "forestgreen": {}, "fuchsia": {}, "gainsboro": {}, "ghostwhite": {},
|
||||
"gold": {}, "goldenrod": {}, "gray": {}, "green": {}, "greenyellow": {}, "grey": {},
|
||||
"honeydew": {}, "hotpink": {}, "indianred": {}, "indigo": {}, "ivory": {},
|
||||
"khaki": {}, "lavender": {}, "lavenderblush": {}, "lawngreen": {}, "lemonchiffon": {},
|
||||
"lightblue": {}, "lightcoral": {}, "lightcyan": {}, "lightgoldenrodyellow": {}, "lightgray": {},
|
||||
"lightgreen": {}, "lightgrey": {}, "lightpink": {}, "lightsalmon": {}, "lightseagreen": {},
|
||||
"lightskyblue": {}, "lightslategray": {}, "lightslategrey": {}, "lightsteelblue": {}, "lightyellow": {},
|
||||
"lime": {}, "limegreen": {}, "linen": {}, "magenta": {}, "maroon": {}, "mediumaquamarine": {},
|
||||
"mediumblue": {}, "mediumorchid": {}, "mediumpurple": {}, "mediumseagreen": {}, "mediumslateblue": {},
|
||||
"mediumspringgreen": {}, "mediumturquoise": {}, "mediumvioletred": {}, "midnightblue": {}, "mintcream": {},
|
||||
"mistyrose": {}, "moccasin": {}, "navajowhite": {}, "navy": {}, "oldlace": {},
|
||||
"olive": {}, "olivedrab": {}, "orange": {}, "orangered": {}, "orchid": {},
|
||||
"palegoldenrod": {}, "palegreen": {}, "paleturquoise": {}, "palevioletred": {}, "papayawhip": {},
|
||||
"peachpuff": {}, "peru": {}, "pink": {}, "plum": {}, "powderblue": {},
|
||||
"purple": {}, "rebeccapurple": {}, "red": {}, "rosybrown": {}, "royalblue": {},
|
||||
"saddlebrown": {}, "salmon": {}, "sandybrown": {}, "seagreen": {}, "seashell": {},
|
||||
"sienna": {}, "silver": {}, "skyblue": {}, "slateblue": {}, "slategray": {},
|
||||
"slategrey": {}, "snow": {}, "springgreen": {}, "steelblue": {}, "tan": {},
|
||||
"teal": {}, "thistle": {}, "tomato": {}, "turquoise": {}, "violet": {},
|
||||
"wheat": {}, "white": {}, "whitesmoke": {}, "yellow": {}, "yellowgreen": {},
|
||||
// And the special values:
|
||||
"transparent": {}, "currentcolor": {},
|
||||
}
|
||||
|
||||
func FallbackColor(val, defaultColor string) string {
|
||||
val = strings.TrimSpace(val)
|
||||
if val == "" {
|
||||
return defaultColor
|
||||
}
|
||||
if hexColorRE.MatchString(val) {
|
||||
return val
|
||||
}
|
||||
lc := strings.ToLower(val)
|
||||
// CSS color functions
|
||||
if strings.HasPrefix(lc, "rgb(") || strings.HasPrefix(lc, "rgba(") ||
|
||||
strings.HasPrefix(lc, "hsl(") || strings.HasPrefix(lc, "hsla(") {
|
||||
return val
|
||||
}
|
||||
// CSS variable
|
||||
if strings.HasPrefix(lc, "var(") {
|
||||
return val
|
||||
}
|
||||
// Named colors and special values
|
||||
if _, ok := cssNamedColors[lc]; ok {
|
||||
return val
|
||||
}
|
||||
// Log a warning if the color is invalid
|
||||
logger.Warningf("Invalid CSS color value provided: '%s'. Falling back to default: '%s'", val, defaultColor)
|
||||
return defaultColor
|
||||
}
|
||||
|
||||
func addCustomTheme(name, description, cssFilePath string) {
|
||||
// Store only file path in config (for YAML export)
|
||||
Config.Frontend.Styling.CustomThemes[name] = CustomTheme{
|
||||
Description: description,
|
||||
CSS: cssFilePath, // Store file path, not content
|
||||
}
|
||||
|
||||
// Load CSS content for runtime use (frontend consumption)
|
||||
cssContent := ""
|
||||
if cssFilePath != "" {
|
||||
var err error
|
||||
cssContent, err = readCustomCSS(cssFilePath)
|
||||
if err != nil {
|
||||
logger.Warning(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
}
|
||||
Config.Frontend.Styling.CustomThemeOptions[name] = CustomTheme{
|
||||
Description: description,
|
||||
CSS: cssFilePath,
|
||||
CssRaw: cssContent, // Store loaded content
|
||||
}
|
||||
logger.Debugf("Added custom theme: %s", name)
|
||||
}
|
||||
|
||||
func readCustomCSS(path string) (string, error) {
|
||||
if path == "" {
|
||||
return "", errors.New("path is empty")
|
||||
}
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("could not read custom CSS file: %v", err)
|
||||
}
|
||||
if len(content) > 128*1024 {
|
||||
return "", fmt.Errorf("custom CSS file is too large (%d bytes)", len(content))
|
||||
}
|
||||
if len(content) == 0 {
|
||||
return "", fmt.Errorf("custom CSS file is empty: %s", path)
|
||||
}
|
||||
logger.Debugf("Loaded custom CSS from: %s (%d bytes)", path, len(content))
|
||||
return string(content), nil
|
||||
}
|
||||
-35
@@ -1,35 +0,0 @@
|
||||
server:
|
||||
numImageProcessors: 4
|
||||
socket: ""
|
||||
tlsKey: ""
|
||||
tlsCert: ""
|
||||
disablePreviews: false
|
||||
disablePreviewResize: false
|
||||
port: 80
|
||||
baseURL: "/"
|
||||
database: "mydb.db"
|
||||
sources:
|
||||
- path: "."
|
||||
integrations:
|
||||
office:
|
||||
url: "http://localhost:9052"
|
||||
secret: 6H1H7GzUk2alRkNxAPav0W02g8JS5oUd
|
||||
auth:
|
||||
methods:
|
||||
password:
|
||||
enabled: true
|
||||
frontend:
|
||||
name: ""
|
||||
disableUsedPercentage: true
|
||||
userDefaults:
|
||||
locale: ""
|
||||
viewMode: ""
|
||||
darkMode: true
|
||||
disableSettings: false
|
||||
singleClick: true
|
||||
permissions:
|
||||
admin: true
|
||||
modify: true
|
||||
share: true
|
||||
api: true
|
||||
dateFormat: false
|
||||
-317
@@ -1,317 +0,0 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestVideoPreviewDefaults(t *testing.T) {
|
||||
// Test 1: No videoPreview config at all - should enable all
|
||||
t.Run("NoVideoPreviewConfig", func(t *testing.T) {
|
||||
config := `
|
||||
server:
|
||||
sources:
|
||||
- path: "."
|
||||
integrations:
|
||||
media:
|
||||
convert:
|
||||
imagePreview:
|
||||
heic: false
|
||||
office:
|
||||
url: "http://localhost:8080"
|
||||
secret: "test-secret"
|
||||
`
|
||||
testVideoPreviewConfig(t, config, func(t *testing.T, config *Settings) {
|
||||
// All video formats should be enabled by default
|
||||
for _, format := range AllVideoPreviewTypes {
|
||||
if !*config.Integrations.Media.Convert.VideoPreview[format] {
|
||||
t.Errorf("Expected %s to be enabled by default, but it was disabled", format)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Test 2: Empty videoPreview config - should enable all
|
||||
t.Run("EmptyVideoPreviewConfig", func(t *testing.T) {
|
||||
config := `
|
||||
server:
|
||||
sources:
|
||||
- path: "."
|
||||
integrations:
|
||||
media:
|
||||
convert:
|
||||
imagePreview:
|
||||
heic: false
|
||||
videoPreview: {}
|
||||
office:
|
||||
url: "http://localhost:8080"
|
||||
secret: "test-secret"
|
||||
`
|
||||
testVideoPreviewConfig(t, config, func(t *testing.T, config *Settings) {
|
||||
// All video formats should be enabled by default
|
||||
for _, format := range AllVideoPreviewTypes {
|
||||
if !*config.Integrations.Media.Convert.VideoPreview[format] {
|
||||
t.Errorf("Expected %s to be enabled by default, but it was disabled", format)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Test 3: Partial videoPreview config - should enable all except explicitly disabled
|
||||
t.Run("PartialVideoPreviewConfig", func(t *testing.T) {
|
||||
config := `
|
||||
server:
|
||||
sources:
|
||||
- path: "."
|
||||
integrations:
|
||||
media:
|
||||
convert:
|
||||
imagePreview:
|
||||
heic: false
|
||||
videoPreview:
|
||||
mp4: true
|
||||
webm: false
|
||||
mkv: false
|
||||
wmv: false
|
||||
office:
|
||||
url: "http://localhost:8080"
|
||||
secret: "test-secret"
|
||||
`
|
||||
testVideoPreviewConfig(t, config, func(t *testing.T, config *Settings) {
|
||||
// Check explicitly set formats
|
||||
if !*config.Integrations.Media.Convert.VideoPreview[MP4VideoPreview] {
|
||||
t.Error("Expected mp4 to be enabled")
|
||||
}
|
||||
if *config.Integrations.Media.Convert.VideoPreview[WebMVideoPreview] {
|
||||
t.Error("Expected webm to be disabled")
|
||||
}
|
||||
if *config.Integrations.Media.Convert.VideoPreview[MKVVideoPreview] {
|
||||
t.Error("Expected mkv to be disabled")
|
||||
}
|
||||
if *config.Integrations.Media.Convert.VideoPreview[WMVVideoPreview] {
|
||||
t.Error("Expected wmv to be disabled")
|
||||
}
|
||||
|
||||
// Check that unmentioned formats are still enabled
|
||||
unmentionedFormats := []VideoPreviewType{
|
||||
AVIVideoPreview, MOVVideoPreview, FLVVideoPreview,
|
||||
M4VVideoPreview, ThreeGPVideoPreview, TSVideoPreview, VOBVideoPreview,
|
||||
}
|
||||
for _, format := range unmentionedFormats {
|
||||
if !*config.Integrations.Media.Convert.VideoPreview[format] {
|
||||
t.Errorf("Expected %s to be enabled (not mentioned in config), but it was disabled", format)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Test 4: All videoPreview config - should respect all settings
|
||||
t.Run("AllVideoPreviewConfig", func(t *testing.T) {
|
||||
config := `
|
||||
server:
|
||||
sources:
|
||||
- path: "."
|
||||
integrations:
|
||||
media:
|
||||
convert:
|
||||
imagePreview:
|
||||
heic: false
|
||||
videoPreview:
|
||||
mp4: true
|
||||
webm: true
|
||||
mov: true
|
||||
avi: false
|
||||
mkv: true
|
||||
flv: false
|
||||
wmv: true
|
||||
m4v: false
|
||||
3gp: true
|
||||
3g2: false
|
||||
ts: true
|
||||
m2ts: false
|
||||
vob: true
|
||||
asf: false
|
||||
mpg: true
|
||||
mpeg: false
|
||||
f4v: true
|
||||
ogv: false
|
||||
office:
|
||||
url: "http://localhost:8080"
|
||||
secret: "test-secret"
|
||||
`
|
||||
testVideoPreviewConfig(t, config, func(t *testing.T, config *Settings) {
|
||||
// Check all explicitly set formats
|
||||
expectedEnabled := []VideoPreviewType{
|
||||
MP4VideoPreview, WebMVideoPreview, MOVVideoPreview, MKVVideoPreview,
|
||||
WMVVideoPreview, ThreeGPVideoPreview, TSVideoPreview, VOBVideoPreview,
|
||||
MPGVideoPreview, F4VVideoPreview,
|
||||
}
|
||||
expectedDisabled := []VideoPreviewType{
|
||||
AVIVideoPreview, FLVVideoPreview, M4VVideoPreview,
|
||||
ThreeGP2VideoPreview, M2TSVideoPreview, ASFVideoPreview,
|
||||
MPEGVideoPreview, OGVVideoPreview,
|
||||
}
|
||||
|
||||
for _, format := range expectedEnabled {
|
||||
if !*config.Integrations.Media.Convert.VideoPreview[format] {
|
||||
t.Errorf("Expected %s to be enabled, but it was disabled", format)
|
||||
}
|
||||
}
|
||||
|
||||
for _, format := range expectedDisabled {
|
||||
if *config.Integrations.Media.Convert.VideoPreview[format] {
|
||||
t.Errorf("Expected %s to be disabled, but it was enabled", format)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Test 5: No integrations section at all - should enable all
|
||||
t.Run("NoIntegrationsSection", func(t *testing.T) {
|
||||
config := `
|
||||
server:
|
||||
sources:
|
||||
- path: "."
|
||||
integrations:
|
||||
office:
|
||||
url: "http://localhost:8080"
|
||||
secret: "test-secret"
|
||||
`
|
||||
testVideoPreviewConfig(t, config, func(t *testing.T, config *Settings) {
|
||||
// All video formats should be enabled by default
|
||||
for _, format := range AllVideoPreviewTypes {
|
||||
if !*config.Integrations.Media.Convert.VideoPreview[format] {
|
||||
t.Errorf("Expected %s to be enabled by default, but it was disabled", format)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestVideoPreviewFormatCategories(t *testing.T) {
|
||||
// Test that all video format categories are properly supported
|
||||
t.Run("FormatCategories", func(t *testing.T) {
|
||||
config := `
|
||||
server:
|
||||
sources:
|
||||
- path: "."
|
||||
integrations:
|
||||
office:
|
||||
url: "http://localhost:8080"
|
||||
secret: "test-secret"
|
||||
`
|
||||
testVideoPreviewConfig(t, config, func(t *testing.T, config *Settings) {
|
||||
// Test format categories
|
||||
categories := map[string][]VideoPreviewType{
|
||||
"Common Web Formats": {MP4VideoPreview, WebMVideoPreview, OGVVideoPreview},
|
||||
"Apple Formats": {MOVVideoPreview, M4VVideoPreview},
|
||||
"Microsoft Formats": {AVIVideoPreview, WMVVideoPreview, ASFVideoPreview},
|
||||
"Open Source": {MKVVideoPreview, FLVVideoPreview},
|
||||
"Mobile Formats": {ThreeGPVideoPreview, ThreeGP2VideoPreview},
|
||||
"Broadcast/Professional": {TSVideoPreview, M2TSVideoPreview},
|
||||
"Legacy Formats": {VOBVideoPreview, MPGVideoPreview, MPEGVideoPreview, F4VVideoPreview},
|
||||
}
|
||||
|
||||
for category, formats := range categories {
|
||||
t.Run(category, func(t *testing.T) {
|
||||
for _, format := range formats {
|
||||
if !*config.Integrations.Media.Convert.VideoPreview[format] {
|
||||
t.Errorf("Expected %s (%s) to be enabled by default, but it was disabled", format, category)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestVideoPreviewFormatCount(t *testing.T) {
|
||||
// Test that we have the expected number of video formats
|
||||
t.Run("FormatCount", func(t *testing.T) {
|
||||
expectedCount := 18
|
||||
actualCount := len(AllVideoPreviewTypes)
|
||||
if actualCount != expectedCount {
|
||||
t.Errorf("Expected %d video formats, but got %d", expectedCount, actualCount)
|
||||
}
|
||||
|
||||
// Test that all formats are unique
|
||||
formatMap := make(map[VideoPreviewType]bool)
|
||||
for _, format := range AllVideoPreviewTypes {
|
||||
if formatMap[format] {
|
||||
t.Errorf("Duplicate video format found: %s", format)
|
||||
}
|
||||
formatMap[format] = true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func BenchmarkVideoPreviewSetup(b *testing.B) {
|
||||
config := `
|
||||
server:
|
||||
sources:
|
||||
- path: "."
|
||||
integrations:
|
||||
media:
|
||||
convert:
|
||||
videoPreview:
|
||||
mp4: true
|
||||
webm: false
|
||||
mkv: true
|
||||
wmv: false
|
||||
office:
|
||||
url: "http://localhost:8080"
|
||||
secret: "test-secret"
|
||||
`
|
||||
|
||||
// Create temporary config file
|
||||
tmpFile, err := os.CreateTemp("", "benchmark_video_preview_*.yaml")
|
||||
if err != nil {
|
||||
b.Fatalf("Error creating temp file: %v", err)
|
||||
}
|
||||
defer os.Remove(tmpFile.Name())
|
||||
|
||||
// Create a temporary directory for cache
|
||||
tmpCacheDir, err := os.MkdirTemp("", "benchmark_cache_*")
|
||||
if err != nil {
|
||||
b.Fatalf("Error creating temp cache dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpCacheDir)
|
||||
|
||||
// Modify config to use absolute temp directory
|
||||
modifiedConfig := strings.Replace(config, "server:", fmt.Sprintf("server:\n cacheDir: %s", tmpCacheDir), 1)
|
||||
|
||||
_, err = tmpFile.WriteString(modifiedConfig)
|
||||
if err != nil {
|
||||
b.Fatalf("Error writing config: %v", err)
|
||||
}
|
||||
tmpFile.Close()
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
Initialize(tmpFile.Name())
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to test video preview configuration
|
||||
func testVideoPreviewConfig(t *testing.T, configContent string, testFunc func(t *testing.T, config *Settings)) {
|
||||
// Create temporary config file
|
||||
tmpFile, err := os.CreateTemp("", "test_video_preview_*.yaml")
|
||||
if err != nil {
|
||||
t.Fatalf("Error creating temp file: %v", err)
|
||||
}
|
||||
defer os.Remove(tmpFile.Name())
|
||||
|
||||
_, err = tmpFile.WriteString(configContent)
|
||||
if err != nil {
|
||||
t.Fatalf("Error writing config: %v", err)
|
||||
}
|
||||
tmpFile.Close()
|
||||
|
||||
// Initialize settings
|
||||
Initialize(tmpFile.Name())
|
||||
|
||||
// Run the test function
|
||||
testFunc(t, &Config)
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// combineYAMLFiles combines multiple YAML files from a directory into a single YAML document
|
||||
// This allows YAML anchors defined in one file to be referenced in another
|
||||
//
|
||||
// Pattern matching: If main config is "config.yaml", only files matching "*-config.yaml" are merged
|
||||
// Examples:
|
||||
// - config.yaml + server-config.yaml + auth-config.yaml
|
||||
// - test.yaml + database-test.yaml + frontend-test.yaml
|
||||
// - myapp.yml + server-myapp.yml
|
||||
func combineYAMLFiles(configFilePath string) ([]byte, error) {
|
||||
// Get absolute path and expand tilde
|
||||
expandedPath := configFilePath
|
||||
if strings.HasPrefix(configFilePath, "~/") {
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to expand home directory: %v", err)
|
||||
}
|
||||
expandedPath = filepath.Join(homeDir, configFilePath[2:])
|
||||
}
|
||||
|
||||
absPath, err := filepath.Abs(expandedPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to resolve config file path: %v", err)
|
||||
}
|
||||
|
||||
configDir := filepath.Dir(absPath)
|
||||
configFileName := filepath.Base(absPath)
|
||||
|
||||
// Extract base name pattern for matching related config files
|
||||
// e.g., "config.yaml" -> match "*-config.yaml"
|
||||
// "test.yml" -> match "*-test.yml"
|
||||
ext := filepath.Ext(configFileName)
|
||||
baseName := strings.TrimSuffix(configFileName, ext)
|
||||
pattern := "-" + baseName + ext
|
||||
|
||||
// Find all YAML files in the directory that match the pattern
|
||||
entries, err := os.ReadDir(configDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read config directory: %v", err)
|
||||
}
|
||||
|
||||
var yamlFiles []string
|
||||
var mainConfigContent []byte
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
name := entry.Name()
|
||||
|
||||
// Skip hidden files (dotfiles like .goreleaser.yaml)
|
||||
if strings.HasPrefix(name, ".") {
|
||||
continue
|
||||
}
|
||||
|
||||
fileExt := strings.ToLower(filepath.Ext(name))
|
||||
|
||||
// Only process .yaml and .yml files
|
||||
if fileExt != ".yaml" && fileExt != ".yml" {
|
||||
continue
|
||||
}
|
||||
|
||||
fullPath := filepath.Join(configDir, name)
|
||||
|
||||
// Keep track of the main config file to process it last
|
||||
if name == configFileName {
|
||||
content, err := os.ReadFile(fullPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read main config file: %v", err)
|
||||
}
|
||||
mainConfigContent = content
|
||||
} else if strings.HasSuffix(name, pattern) {
|
||||
// Only include files that match the pattern (e.g., *-config.yaml)
|
||||
yamlFiles = append(yamlFiles, fullPath)
|
||||
}
|
||||
}
|
||||
|
||||
// Sort the auxiliary files for consistent ordering
|
||||
sort.Strings(yamlFiles)
|
||||
|
||||
// Combine all YAML files as raw text into a single document
|
||||
// We do NOT use `---` separators because anchors don't work across document boundaries
|
||||
var combined strings.Builder
|
||||
|
||||
// First, add all auxiliary YAML files (these typically contain anchor definitions)
|
||||
for _, file := range yamlFiles {
|
||||
content, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read YAML file %s: %v", file, err)
|
||||
}
|
||||
combined.Write(content)
|
||||
combined.WriteString("\n") // Add newline between files
|
||||
}
|
||||
|
||||
// Finally, add the main config file (this typically contains the references)
|
||||
if mainConfigContent != nil {
|
||||
combined.Write(mainConfigContent)
|
||||
}
|
||||
|
||||
return []byte(combined.String()), nil
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"ghsa": "GHSA-3m6q-jj5j-38c9", "repo": "ohler55/oj", "cwes": ["CWE-787", "CWE-125"], "summary": "Fix Oj::Doc where counter is not reset on raise", "fix_commit": "192153b6a7d651d7dbb4300548f582aded9155d7", "parent_commit": "5e914b8b29f9fa612f02b628500c538a86152040", "vuln_files": ["ext/oj/fast.c"], "vuln_lines": {"ext/oj/fast.c": [948]}, "source_files": 68, "scope_dirs": ["ext/oj"], "source_note": "Subset: only the ext/oj C-extension package (containing the vuln file) was copied into source/, not the full repo."}
|
||||
@@ -0,0 +1 @@
|
||||
{"ghsa": "GHSA-72f5-rr8c-r6gr", "repo": "fluent/fluentd", "cwes": ["CWE-918"], "summary": "out_http: restrict placeholder-derived endpoint hosts to an allowed_hosts list (SSRF)", "fix_commit": "e175c1850980dc87d37add2026314924b2b51f29", "parent_commit": "6c88e14e120316b9e83e29bc6d716b4313425004", "vuln_files": ["lib/fluent/plugin/out_http.rb"], "vuln_lines": {"lib/fluent/plugin/out_http.rb": [60, 148, 281]}, "source_files": 93, "scope_dirs": ["lib/fluent/plugin"], "source_note": "Subset: only the lib/fluent/plugin package (containing the vuln file) was copied into source/, not the full repo."}
|
||||
@@ -0,0 +1 @@
|
||||
{"ghsa": "GHSA-86vw-mfpg-wwv9", "repo": "jsonata-js/jsonata", "cwes": ["CWE-1333"], "summary": "fix $toMillis() with more than 3 digit fractional seconds (#782)", "fix_commit": "80ba95d170f74e3f20f4f36b8b77d8c85cea7686", "parent_commit": "1c9665056e4217d46b44c859fe128c96d5c5bc3f", "vuln_files": ["src/datetime.js"], "vuln_lines": {"src/datetime.js": [996]}, "source_files": 6, "scope_dirs": ["src"], "source_note": "Subset: only the src/ package (containing the vuln file) was copied into source/, not the full repo."}
|
||||
@@ -0,0 +1 @@
|
||||
{"ghsa": "GHSA-97vg-427p-8hx5", "repo": "surrealdb/surrealdb", "cwes": ["CWE-918"], "summary": "Fix HTTP redirect handling and DNS resolution filtering (#221)", "fix_commit": "ceb1ca3a10ea7789c990a5d797f3ebd725ae407f", "parent_commit": "8856ab3c9161f5fdd882f19e1c4a3e8ebff8cf96", "vuln_files": ["surrealdb/core/src/http/mod.rs", "surrealdb/core/src/http/resolve.rs"], "vuln_lines": {"surrealdb/core/src/http/mod.rs": [44, 68, 70, 71, 73, 74, 75, 78, 79], "surrealdb/core/src/http/resolve.rs": [10, 46, 47, 52]}, "source_files": 2, "scope_dirs": ["surrealdb/core/src/http"], "source_note": "Subset: only the surrealdb/core/src/http directory (containing the vuln files mod.rs and resolve.rs) was copied into source/, not the full surrealdb repo."}
|
||||
@@ -1,6 +0,0 @@
|
||||
"""RAID is ManoMano's internal issue dispatcher.
|
||||
|
||||
It works with Slack and Jira to create, update and close issues.
|
||||
|
||||
It is currently not configurable.
|
||||
"""
|
||||
@@ -1,40 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from django.contrib import admin
|
||||
from import_export.admin import ImportExportModelAdmin
|
||||
|
||||
from firefighter.jira_app.admin import JiraIssueAdmin
|
||||
from firefighter.raid.models import (
|
||||
FeatureTeam,
|
||||
JiraTicket,
|
||||
JiraTicketImpact,
|
||||
)
|
||||
from firefighter.raid.resources import FeatureTeamResource
|
||||
|
||||
|
||||
class JiraTicketImpactInline(admin.TabularInline[JiraTicketImpact, JiraTicket]):
|
||||
model = JiraTicketImpact
|
||||
extra = 0
|
||||
raw_id_fields = ["impact"]
|
||||
|
||||
|
||||
@admin.register(JiraTicket)
|
||||
class JiraTicketAdmin(JiraIssueAdmin):
|
||||
model = JiraTicket
|
||||
list_display = ["id", "key", "summary", "incident"]
|
||||
list_display_links = ["id", "key", "summary"]
|
||||
search_fields = ["id", "key", "summary", "description"]
|
||||
list_select_related = ["incident"]
|
||||
|
||||
autocomplete_fields = ["watchers", "assignee", "reporter", "incident"]
|
||||
inlines = [JiraTicketImpactInline]
|
||||
|
||||
|
||||
@admin.register(FeatureTeam)
|
||||
class FeatureTeamAdmin(ImportExportModelAdmin):
|
||||
resource_class = FeatureTeamResource
|
||||
model = FeatureTeam
|
||||
list_display = [
|
||||
"name",
|
||||
"jira_project_key",
|
||||
]
|
||||
@@ -1,37 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from django.apps import AppConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RaidConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
label = "raid"
|
||||
name = "firefighter.raid"
|
||||
verbose_name = "RAID"
|
||||
|
||||
def ready(self) -> None:
|
||||
import firefighter.raid.tasks
|
||||
import firefighter.raid.urls
|
||||
from firefighter.raid.signals import (
|
||||
incident_updated,
|
||||
)
|
||||
from firefighter.slack.views.modals.open import INCIDENT_TYPES
|
||||
from firefighter.slack.views.modals.opening.details.unified import (
|
||||
OpeningUnifiedModal,
|
||||
)
|
||||
|
||||
# Use unified form for all normal incidents (P4-P5)
|
||||
# This replaces the previous 5 separate forms (Customer/Seller/Internal/Doc/Feature)
|
||||
# STEP 3 (incident type selection) will be automatically hidden since len() == 1
|
||||
INCIDENT_TYPES["normal"] = {
|
||||
"normal": {
|
||||
"label": "Normal",
|
||||
"slack_form": OpeningUnifiedModal,
|
||||
},
|
||||
}
|
||||
|
||||
return super().ready()
|
||||
@@ -1,225 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any, Final, cast
|
||||
|
||||
from django.conf import settings
|
||||
from httpx import HTTPError
|
||||
from jira.exceptions import JIRAError
|
||||
|
||||
from firefighter.firefighter.http_client import HttpClient
|
||||
from firefighter.firefighter.utils import get_in
|
||||
from firefighter.jira_app.client import JiraClient
|
||||
from firefighter.raid.models import FeatureTeam
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from jira import Project
|
||||
|
||||
from firefighter.jira_app.types import WorkflowBuilderResponse
|
||||
from firefighter.raid.types import (
|
||||
JiraObject,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
RAID_JIRA_PROJECT_KEY: Final[str] = settings.RAID_JIRA_PROJECT_KEY
|
||||
TOOLBOX_URL: Final[str] = settings.RAID_TOOLBOX_URL
|
||||
# XXX Do not hardcode this, it should be a setting or fetched from Jira
|
||||
RAID_JIRA_WORKFLOW_NAME: Final[str] = "Incident workflow - v2023.03.13"
|
||||
TARGET_STATUS_NAME: Final[str] = "Closed"
|
||||
|
||||
|
||||
class JiraAttachmentError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class RaidJiraClient(JiraClient):
|
||||
def create_issue( # noqa: PLR0912, PLR0913, C901
|
||||
self,
|
||||
issuetype: str | None,
|
||||
summary: str,
|
||||
description: str,
|
||||
assignee: str | None,
|
||||
reporter: str,
|
||||
priority: int | None,
|
||||
labels: list[str] | None = None,
|
||||
zoho_desk_ticket_id: str | int | None = None,
|
||||
zendesk_ticket_id: str | int | None = None,
|
||||
is_seller_in_golden_list: bool | None = None,
|
||||
is_key_account: bool | None = None,
|
||||
seller_contract_id: int | str | None = None,
|
||||
suggested_team_routing: str | None = None,
|
||||
business_impact: str | None = None,
|
||||
platform: str | None = None,
|
||||
environments: list[str] | None = None,
|
||||
incident_category: str | None = None,
|
||||
project: str | None = None,
|
||||
) -> JiraObject:
|
||||
description_addendum: list[str] = []
|
||||
extra_args: dict[str, Any] = {}
|
||||
if assignee:
|
||||
extra_args["assignee"] = {"id": assignee}
|
||||
if labels is None:
|
||||
labels = [""]
|
||||
if priority is None:
|
||||
priority_value = "-"
|
||||
else:
|
||||
if not 1 <= priority <= 5:
|
||||
raise ValueError("Priority must be between 1 and 5")
|
||||
priority_value = str(priority)
|
||||
if zoho_desk_ticket_id:
|
||||
extra_args["customfield_10896"] = str(zoho_desk_ticket_id)
|
||||
if zendesk_ticket_id:
|
||||
extra_args["customfield_10895"] = str(zendesk_ticket_id)
|
||||
|
||||
if seller_contract_id:
|
||||
description_addendum.append(
|
||||
f"Seller link to TOOLBOX: {TOOLBOX_URL}?seller_id={seller_contract_id}"
|
||||
)
|
||||
extra_args["customfield_10908"] = str(seller_contract_id)
|
||||
if is_seller_in_golden_list is True:
|
||||
labels = [*labels, "goldenList"]
|
||||
if is_key_account is True:
|
||||
labels = [*labels, "keyAccount"]
|
||||
if suggested_team_routing and suggested_team_routing != "SBI":
|
||||
description_addendum.append(
|
||||
f"Suggested team to be routed: *{suggested_team_routing}*"
|
||||
)
|
||||
if business_impact and business_impact != "N/A":
|
||||
if business_impact not in {"Lowest", "Low", "Medium", "High", "Highest"}:
|
||||
raise ValueError("Business impact must be Lowest, Low, Medium, High or Highest")
|
||||
extra_args["customfield_10936"] = {"value": business_impact}
|
||||
if platform:
|
||||
platform = platform.replace("platform-", "")
|
||||
extra_args["customfield_10201"] = {"value": platform}
|
||||
if environments:
|
||||
extra_args["customfield_11049"] = [{"value": e} for e in environments]
|
||||
if incident_category and settings.RAID_JIRA_INCIDENT_CATEGORY_FIELD:
|
||||
extra_args[settings.RAID_JIRA_INCIDENT_CATEGORY_FIELD] = {"value": incident_category}
|
||||
if len(description_addendum) > 0:
|
||||
description_addendum_str = "\n *Additional Information* \n" + "\n".join(
|
||||
description_addendum
|
||||
)
|
||||
description += description_addendum_str
|
||||
if project is None:
|
||||
try:
|
||||
feature_team = FeatureTeam.objects.get(name=suggested_team_routing)
|
||||
except FeatureTeam.DoesNotExist:
|
||||
feature_team = None
|
||||
project = (
|
||||
feature_team.jira_project_key if feature_team else RAID_JIRA_PROJECT_KEY
|
||||
)
|
||||
|
||||
issue = self.jira.create_issue(
|
||||
project=project,
|
||||
summary=summary,
|
||||
description=description,
|
||||
issuetype={"name": issuetype},
|
||||
reporter={"id": reporter},
|
||||
customfield_11064={"value": priority_value},
|
||||
labels=labels,
|
||||
**extra_args,
|
||||
)
|
||||
return self._jira_object(issue.raw)
|
||||
|
||||
def get_projects(self) -> list[Project]:
|
||||
return self.jira.projects()
|
||||
|
||||
def update_issue_fields(self, issue_id: str | int, **fields: Any) -> None:
|
||||
"""Update Jira issue fields (supports custom fields like customfield_11064)."""
|
||||
issue = self.jira.issue(str(issue_id))
|
||||
issue.update(fields=fields)
|
||||
|
||||
@staticmethod
|
||||
def add_attachments_to_issue(issue_id: str | int, urls: list[str]) -> None:
|
||||
"""Add attachments to a Jira issue.
|
||||
|
||||
Args:
|
||||
issue_id (str | int): the Jira issue id
|
||||
urls (list[str]): list of urls to the attachments
|
||||
|
||||
Raises:
|
||||
JiraAttachmentError: if there is an error while adding any attachment
|
||||
"""
|
||||
http_client = HttpClient()
|
||||
for i, url in enumerate(urls):
|
||||
index = url.rfind(".")
|
||||
extension = url[index:]
|
||||
try:
|
||||
response = http_client.get(url)
|
||||
response.raise_for_status() # Raises an exception if status code is not 2XX
|
||||
|
||||
in_memory_file = io.BytesIO(response.content)
|
||||
|
||||
client.jira.add_attachment(
|
||||
issue=issue_id,
|
||||
attachment=in_memory_file,
|
||||
filename=f"image{i}{extension}",
|
||||
)
|
||||
|
||||
except (HTTPError, JIRAError) as err:
|
||||
msg = f"Error while adding attachment to issue: {err}"
|
||||
raise JiraAttachmentError(msg) from err
|
||||
|
||||
def close_issue(
|
||||
self,
|
||||
issue_id: str | int,
|
||||
) -> None:
|
||||
return self.transition_issue_auto(
|
||||
issue_id, TARGET_STATUS_NAME, RAID_JIRA_WORKFLOW_NAME
|
||||
)
|
||||
|
||||
def _get_project_config_workflow(
|
||||
self, project_key: str = RAID_JIRA_PROJECT_KEY
|
||||
) -> dict[str, Any]:
|
||||
return self._get_project_config_workflow_base(
|
||||
project_key, RAID_JIRA_WORKFLOW_NAME
|
||||
)
|
||||
|
||||
def _get_project_config_workflow_from_builder(self) -> WorkflowBuilderResponse:
|
||||
return self._get_project_config_workflow_from_builder_base(
|
||||
RAID_JIRA_WORKFLOW_NAME
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _jira_object(issue: dict[str, Any]) -> JiraObject:
|
||||
if issue_id := issue.get("id"):
|
||||
jira_id = int(cast("str", issue_id))
|
||||
else:
|
||||
raise TypeError("Jira ID not found")
|
||||
|
||||
jira_key = issue.get("key")
|
||||
jira_assignee_id = get_in(issue, ["fields", "assignee", "accountId"])
|
||||
jira_reporter_id = get_in(issue, ["fields", "reporter", "accountId"])
|
||||
jira_description = get_in(issue, ["fields", "description"])
|
||||
jira_summary = get_in(issue, ["fields", "summary"])
|
||||
jira_issue_type = get_in(issue, ["fields", "issuetype", "name"])
|
||||
# If any field is not string
|
||||
if None in {
|
||||
jira_reporter_id,
|
||||
jira_description,
|
||||
jira_summary,
|
||||
jira_issue_type,
|
||||
}:
|
||||
raise TypeError("Jira object has wrong type")
|
||||
|
||||
if jira_key is None:
|
||||
raise TypeError("Jira key is None")
|
||||
|
||||
return {
|
||||
"id": jira_id,
|
||||
"key": jira_key,
|
||||
"project_key": str(jira_key.split("-")[0]),
|
||||
"assignee_id": jira_assignee_id,
|
||||
"reporter_id": jira_reporter_id,
|
||||
"description": jira_description,
|
||||
"summary": jira_summary,
|
||||
"issue_type": jira_issue_type,
|
||||
"business_impact": get_in(
|
||||
issue, ["fields", "customfield_10936", "value"], default=""
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
client = RaidJiraClient()
|
||||
@@ -1,337 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any, Never
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
from slack_sdk.errors import SlackApiError
|
||||
|
||||
from firefighter.incidents.forms.select_impact import SelectImpactForm
|
||||
from firefighter.incidents.models.priority import Priority
|
||||
from firefighter.jira_app.client import (
|
||||
JiraAPIError,
|
||||
JiraUserNotFoundError,
|
||||
)
|
||||
from firefighter.jira_app.models import JiraUser
|
||||
from firefighter.raid.client import client as jira_client
|
||||
from firefighter.raid.messages import (
|
||||
SlackMessageRaidComment,
|
||||
SlackMessageRaidCreatedIssue,
|
||||
SlackMessageRaidModifiedIssue,
|
||||
)
|
||||
from firefighter.raid.models import JiraTicket
|
||||
from firefighter.raid.utils import get_domain_from_email
|
||||
from firefighter.slack.models.conversation import Conversation
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from django.db.models import QuerySet
|
||||
|
||||
from firefighter.incidents.models.impact import ImpactLevel
|
||||
from firefighter.incidents.models.user import User
|
||||
from firefighter.raid.types import JiraObject
|
||||
from firefighter.slack.messages.base import SlackMessageSurface
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
RAID_DEFAULT_JIRA_QRAFT_USER_ID: str = settings.RAID_DEFAULT_JIRA_QRAFT_USER_ID
|
||||
|
||||
|
||||
class PlatformChoices(models.TextChoices):
|
||||
FR = "platform-FR", ":fr: FR"
|
||||
DE = "platform-DE", ":de: DE"
|
||||
IT = "platform-IT", ":it: IT"
|
||||
ES = "platform-ES", ":es: ES"
|
||||
UK = "platform-UK", ":uk: UK"
|
||||
ALL = "platform-All", ":earth_africa: ALL"
|
||||
INTERNAL = "platform-Internal", ":logo-manomano: Internal"
|
||||
|
||||
|
||||
def initial_priority() -> Priority:
|
||||
return Priority.objects.get(default=True)
|
||||
|
||||
|
||||
# NOTE: Incident creation forms have been unified and moved to:
|
||||
# firefighter.incidents.forms.unified_incident.UnifiedIncidentForm
|
||||
# This handles all incident types (P1-P5) with dynamic field visibility.
|
||||
# The following utility functions remain here as they are used by the unified form
|
||||
# and by JIRA webhook handlers.
|
||||
|
||||
|
||||
def process_jira_issue(
|
||||
issue_data: JiraObject,
|
||||
user: User,
|
||||
jira_user: JiraUser,
|
||||
impacts_data: dict[str, ImpactLevel],
|
||||
*args: Never,
|
||||
**kwargs: Never,
|
||||
) -> None:
|
||||
# XXX Deduplicate from these forms and from incident_created signal
|
||||
jira_ticket = JiraTicket.objects.create(**issue_data)
|
||||
impacts_form = SelectImpactForm(impacts_data)
|
||||
|
||||
impacts_form.save(incident=jira_ticket)
|
||||
|
||||
set_jira_ticket_watchers_raid(jira_ticket)
|
||||
alert_slack_new_jira_ticket(jira_ticket)
|
||||
|
||||
|
||||
def set_jira_ticket_watchers_raid(jira_ticket: JiraTicket) -> None:
|
||||
issue_id = jira_ticket.id
|
||||
reporter = jira_ticket.reporter.id
|
||||
|
||||
try:
|
||||
default_jira_user = jira_client.get_jira_user_from_jira_id(
|
||||
RAID_DEFAULT_JIRA_QRAFT_USER_ID
|
||||
)
|
||||
except JiraUserNotFoundError:
|
||||
logger.exception(
|
||||
f"Could not find Jira user with account id {RAID_DEFAULT_JIRA_QRAFT_USER_ID}"
|
||||
)
|
||||
try:
|
||||
jira_client.jira.add_watcher(issue=issue_id, watcher=reporter)
|
||||
except JiraAPIError:
|
||||
logger.exception(
|
||||
f"Could not add the watcher {jira_ticket.reporter.id} to the ticket {issue_id}"
|
||||
)
|
||||
try:
|
||||
jira_client.jira.remove_watcher(issue=issue_id, watcher=default_jira_user)
|
||||
|
||||
except JiraAPIError:
|
||||
logger.exception(
|
||||
f"Could not add the watcher {jira_ticket.reporter.id} to the ticket {issue_id}"
|
||||
)
|
||||
|
||||
|
||||
def alert_slack_new_jira_ticket(
|
||||
jira_ticket: JiraTicket,
|
||||
reporter_user: User | None = None,
|
||||
reporter_email: str | None = None,
|
||||
) -> None:
|
||||
# These alerts are for P4-P5 incidents only, not P1-P3 critical incidents
|
||||
if (
|
||||
hasattr(jira_ticket, "incident")
|
||||
and jira_ticket.incident
|
||||
and jira_ticket.incident.priority.value <= 3
|
||||
):
|
||||
raise ValueError("This is a critical incident, not a raid incident.")
|
||||
|
||||
# Get the reporter's email and user from ticket if not provided
|
||||
reporter_user = reporter_user or jira_ticket.reporter.user
|
||||
reporter_email = reporter_email or reporter_user.email
|
||||
# Get the user's email domain (without @, without subdomain(s), with tld)
|
||||
user_domain: str = get_domain_from_email(reporter_email)
|
||||
|
||||
if not reporter_user:
|
||||
logger.warning(f"Reporter user not found for Jira ticket {jira_ticket.id}.")
|
||||
return
|
||||
message = SlackMessageRaidCreatedIssue(jira_ticket, reporter_user=reporter_user)
|
||||
if (
|
||||
hasattr(reporter_user, "slack_user")
|
||||
and reporter_user.slack_user
|
||||
and reporter_user.slack_user.slack_id
|
||||
):
|
||||
try:
|
||||
# XXX: Slack ID is posted in the message instead of Name
|
||||
reporter_user.slack_user.send_private_message(
|
||||
message,
|
||||
unfurl_links=False,
|
||||
)
|
||||
except SlackApiError as e:
|
||||
if e.response.get("error") == "messages_tab_disabled":
|
||||
logger.warning(
|
||||
f"User {reporter_user.slack_user} has disabled private messages from bots"
|
||||
)
|
||||
else:
|
||||
logger.exception(
|
||||
f"Couldn't send private message to reporter {reporter_user.slack_user}"
|
||||
)
|
||||
|
||||
# Get the right channels from tags
|
||||
channels = get_internal_alert_conversations(jira_ticket)
|
||||
if user_domain:
|
||||
channels.union(get_partner_alert_conversations(user_domain))
|
||||
channels_list = list(channels)
|
||||
if len(channels_list) == 0:
|
||||
logger.warning(
|
||||
f"No channel to send notification for Jira ticket {jira_ticket.id}."
|
||||
)
|
||||
|
||||
for channel in channels_list:
|
||||
try:
|
||||
channel.send_message_and_save(message)
|
||||
except SlackApiError:
|
||||
logger.exception(
|
||||
f"Couldn't send message to channel {channel} for ticket {jira_ticket.id}"
|
||||
)
|
||||
|
||||
|
||||
def alert_slack_update_ticket(
|
||||
jira_ticket_id: int,
|
||||
jira_ticket_key: str,
|
||||
jira_author_name: str,
|
||||
jira_field_modified: str,
|
||||
jira_field_from: str,
|
||||
jira_field_to: str,
|
||||
) -> bool:
|
||||
message = SlackMessageRaidModifiedIssue(
|
||||
jira_ticket_key=jira_ticket_key,
|
||||
jira_author_name=jira_author_name,
|
||||
jira_field_modified=jira_field_modified,
|
||||
jira_field_from=jira_field_from,
|
||||
jira_field_to=jira_field_to,
|
||||
)
|
||||
return send_message_to_watchers(jira_issue_id=jira_ticket_id, message=message)
|
||||
|
||||
|
||||
def alert_slack_comment_ticket(
|
||||
webhook_event: str,
|
||||
jira_ticket_id: int,
|
||||
jira_ticket_key: str,
|
||||
author_jira_name: str,
|
||||
comment: str,
|
||||
) -> bool:
|
||||
message: SlackMessageSurface = SlackMessageRaidComment(
|
||||
jira_ticket_key=jira_ticket_key,
|
||||
author_jira_name=author_jira_name,
|
||||
comment=comment,
|
||||
webhook_event=webhook_event,
|
||||
)
|
||||
return send_message_to_watchers(jira_issue_id=jira_ticket_id, message=message)
|
||||
|
||||
|
||||
def send_message_to_watchers(
|
||||
jira_issue_id: int,
|
||||
message: SlackMessageSurface,
|
||||
) -> bool:
|
||||
watchers = jira_client.get_watchers_from_jira_ticket(jira_issue_id)
|
||||
if not watchers:
|
||||
return True
|
||||
|
||||
for watcher in watchers:
|
||||
try:
|
||||
watcher_account_id = watcher.get("accountId")
|
||||
if watcher_account_id is None:
|
||||
logger.warning(f"Couldn't find Jira account ID for watcher {watcher}")
|
||||
continue
|
||||
|
||||
if watcher.get("accountType") == "app":
|
||||
logger.info(
|
||||
f"Skipping sending message to jira_user_id={watcher_account_id}: is an app"
|
||||
)
|
||||
continue
|
||||
if not JiraUser.objects.filter(id=watcher_account_id).exists():
|
||||
logger.debug(
|
||||
"Skipping watcher %s: no JiraUser mapping in database",
|
||||
watcher_account_id,
|
||||
)
|
||||
continue
|
||||
watcher_jira_user = jira_client.get_jira_user_from_jira_id(
|
||||
watcher_account_id
|
||||
)
|
||||
watcher_slack_user = (
|
||||
watcher_jira_user.user.slack_user
|
||||
if hasattr(watcher_jira_user.user, "slack_user")
|
||||
else None
|
||||
)
|
||||
if watcher_slack_user is None:
|
||||
logger.debug(
|
||||
"Watcher %s has a JiraUser but no linked Slack user",
|
||||
watcher_account_id,
|
||||
)
|
||||
continue
|
||||
watcher_slack_user.send_private_message(
|
||||
message,
|
||||
unfurl_links=False,
|
||||
)
|
||||
except SlackApiError:
|
||||
logger.warning(
|
||||
f"Couldn't send private message to reporter with jira_id={watcher.get('accountId', watcher)}"
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def get_business_impact(impacts_data: dict[str, ImpactLevel]) -> str | None:
|
||||
impact_form = SelectImpactForm(impacts_data)
|
||||
return impact_form.business_impact_new
|
||||
|
||||
|
||||
def prepare_jira_fields(
|
||||
*,
|
||||
title: str,
|
||||
description: str,
|
||||
priority: int,
|
||||
reporter: str,
|
||||
incident_category: str,
|
||||
environments: list[str],
|
||||
platforms: list[str],
|
||||
impacts_data: dict[str, ImpactLevel],
|
||||
optional_fields: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Prepare all fields for jira_client.create_issue().
|
||||
|
||||
This function centralizes Jira field preparation for both P1-P3 and P4-P5 incidents,
|
||||
ensuring all custom fields are properly passed.
|
||||
|
||||
Args:
|
||||
title: Incident title
|
||||
description: Incident description
|
||||
priority: Priority value (1-5)
|
||||
reporter: Jira user account ID
|
||||
incident_category: Category name
|
||||
environments: List of environment values (e.g. ["PRD", "STG"])
|
||||
platforms: List of platform values (e.g. ["platform-FR", "platform-DE"])
|
||||
impacts_data: Dictionary of impact data for business_impact computation
|
||||
optional_fields: Optional dictionary containing:
|
||||
- zendesk_ticket_id: Zendesk ticket ID (customer-specific)
|
||||
- seller_contract_id: Seller contract ID (seller-specific)
|
||||
- zoho_desk_ticket_id: Zoho Desk ticket ID (seller-specific)
|
||||
- is_key_account: Key account flag (seller-specific)
|
||||
- is_seller_in_golden_list: Golden list flag (seller-specific)
|
||||
- suggested_team_routing: Suggested team routing (P4-P5 only)
|
||||
|
||||
Returns:
|
||||
Dictionary of kwargs ready for jira_client.create_issue()
|
||||
"""
|
||||
business_impact = get_business_impact(impacts_data)
|
||||
platform = platforms[0] if platforms else PlatformChoices.ALL.value
|
||||
|
||||
# Extract optional fields with defaults
|
||||
opt = optional_fields or {}
|
||||
zendesk_ticket_id = opt.get("zendesk_ticket_id", "")
|
||||
seller_contract_id = opt.get("seller_contract_id", "")
|
||||
zoho_desk_ticket_id = opt.get("zoho_desk_ticket_id", "")
|
||||
is_key_account = opt.get("is_key_account")
|
||||
is_seller_in_golden_list = opt.get("is_seller_in_golden_list")
|
||||
suggested_team_routing = opt.get("suggested_team_routing")
|
||||
|
||||
return {
|
||||
"issuetype": "Incident",
|
||||
"summary": title,
|
||||
"description": description,
|
||||
"priority": priority,
|
||||
"reporter": reporter,
|
||||
"assignee": None,
|
||||
"incident_category": incident_category,
|
||||
"environments": environments, # ✅ Always pass environments list
|
||||
"platform": platform,
|
||||
"business_impact": business_impact,
|
||||
"zendesk_ticket_id": zendesk_ticket_id,
|
||||
"seller_contract_id": seller_contract_id,
|
||||
"zoho_desk_ticket_id": zoho_desk_ticket_id,
|
||||
"is_key_account": is_key_account if is_key_account is not None else False,
|
||||
"is_seller_in_golden_list": is_seller_in_golden_list if is_seller_in_golden_list is not None else False,
|
||||
"suggested_team_routing": suggested_team_routing,
|
||||
}
|
||||
|
||||
|
||||
def get_partner_alert_conversations(user_domain: str) -> QuerySet[Conversation]:
|
||||
# Get the right channel from tags
|
||||
return Conversation.objects.filter(tag__contains=f"raid_alert__{user_domain}")
|
||||
|
||||
|
||||
def get_internal_alert_conversations(jira_ticket: JiraTicket) -> QuerySet[Conversation]:
|
||||
impact_tag = "high" if jira_ticket.business_impact == "High" else "normal"
|
||||
project = "sbi" if jira_ticket.project_key == "SBI" else "incidents"
|
||||
return Conversation.objects.filter(
|
||||
tag__contains=f"raid_alert__{project}_{impact_tag}"
|
||||
)
|
||||
@@ -1,190 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import textwrap
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from django.conf import settings
|
||||
from slack_sdk.models.blocks.basic_components import MarkdownTextObject
|
||||
from slack_sdk.models.blocks.blocks import (
|
||||
Block,
|
||||
DividerBlock,
|
||||
SectionBlock,
|
||||
)
|
||||
|
||||
from firefighter.slack.messages.base import SlackMessageSurface
|
||||
from firefighter.slack.slack_templating import user_slack_handle_or_name
|
||||
|
||||
RAID_JIRA_API_URL: str = settings.RAID_JIRA_API_URL
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from firefighter.incidents.models.user import User
|
||||
from firefighter.raid.models import JiraTicket
|
||||
|
||||
|
||||
class SlackMessageRaidCreatedIssue(SlackMessageSurface):
|
||||
id = "raid_created_issue"
|
||||
|
||||
def __init__(self, ticket: JiraTicket, reporter_user: User | None = None) -> None:
|
||||
self.ticket = ticket
|
||||
self.reporter_user = reporter_user or ticket.reporter.user
|
||||
super().__init__()
|
||||
|
||||
def get_text(self) -> str:
|
||||
return f"New {self.ticket.issue_type}: {self.ticket.key} '{self.ticket.summary}' was created."
|
||||
|
||||
def get_blocks(self) -> list[Block]:
|
||||
blocks: list[Block] = [
|
||||
SectionBlock(
|
||||
text=f"New {self.ticket.issue_type}: *{self.ticket.key}* _ {self.ticket.summary} _ was created."
|
||||
),
|
||||
# XXX Add issue type emoji from RAID
|
||||
DividerBlock(),
|
||||
SectionBlock(
|
||||
fields=[
|
||||
MarkdownTextObject(text=f":tag: *Title:*\n{self.ticket.summary}"),
|
||||
MarkdownTextObject(
|
||||
text=textwrap.shorten(
|
||||
f":spiral_note_pad: *Description:*\n{self.ticket.description}",
|
||||
width=1997,
|
||||
placeholder="...",
|
||||
)
|
||||
),
|
||||
MarkdownTextObject(
|
||||
text=f":happydoge: *Reporter:*\n{user_slack_handle_or_name(self.reporter_user)}"
|
||||
),
|
||||
MarkdownTextObject(text=f":jira_new: *Jira:*\n{self.ticket.url}"),
|
||||
MarkdownTextObject(
|
||||
text=f":warning: *Business impact*: {self.ticket.business_impact or 'N/A'}"
|
||||
),
|
||||
]
|
||||
),
|
||||
]
|
||||
return blocks
|
||||
|
||||
|
||||
class SlackMessageRaidModifiedIssue(SlackMessageSurface):
|
||||
id = "raid_modify_issue"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
jira_ticket_key: str,
|
||||
jira_author_name: str,
|
||||
jira_field_modified: str,
|
||||
jira_field_from: str,
|
||||
jira_field_to: str,
|
||||
) -> None:
|
||||
self.jira_ticket_key = jira_ticket_key
|
||||
self.jira_author_name = jira_author_name
|
||||
self.jira_field_modified = jira_field_modified
|
||||
self.jira_field_from = jira_field_from
|
||||
self.jira_field_to = jira_field_to
|
||||
super().__init__()
|
||||
|
||||
def get_text(self) -> str:
|
||||
return f"Modified Jira ticket {self.jira_ticket_key}"
|
||||
|
||||
def get_blocks(self) -> list[Block]:
|
||||
return [
|
||||
SectionBlock(
|
||||
text=f":jira_new: Jira ticket *{self.jira_ticket_key}* was *modified* :hammer:"
|
||||
),
|
||||
DividerBlock(),
|
||||
SectionBlock(
|
||||
fields=[
|
||||
MarkdownTextObject(
|
||||
text=f":face_with_monocle: *Author:*\n{self.jira_author_name}"
|
||||
),
|
||||
MarkdownTextObject(
|
||||
text=f":tag: *Field:*\n{self.jira_field_modified}"
|
||||
),
|
||||
MarkdownTextObject(text=f":x: *From:*\n{self.jira_field_from}"),
|
||||
MarkdownTextObject(
|
||||
text=f":light_check_mark: *To:*\n{self.jira_field_to}"
|
||||
),
|
||||
MarkdownTextObject(
|
||||
text=f":jira_new: *URL:*\n{RAID_JIRA_API_URL}/browse/{self.jira_ticket_key}"
|
||||
),
|
||||
]
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
class SlackMessageRaidComment(SlackMessageSurface):
|
||||
id = "raid_comment"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
jira_ticket_key: str,
|
||||
author_jira_name: str,
|
||||
comment: str,
|
||||
webhook_event: str,
|
||||
) -> None:
|
||||
self.jira_ticket_key = jira_ticket_key
|
||||
self.author_jira_name = author_jira_name
|
||||
self.comment = comment
|
||||
self.webhook_event = webhook_event
|
||||
match self.webhook_event:
|
||||
case "comment_created":
|
||||
self.comment_event_text = ":green_book: New comment added"
|
||||
case "comment_updated":
|
||||
self.comment_event_text = ":blue_book: Updated comment"
|
||||
case "comment_deleted":
|
||||
self.comment_event_text = ":closed_book: Deleted comment"
|
||||
super().__init__()
|
||||
|
||||
def get_text(self) -> str:
|
||||
return f"{self.comment_event_text} on {self.jira_ticket_key}"
|
||||
|
||||
def get_blocks(self) -> list[Block]:
|
||||
return [
|
||||
SectionBlock(
|
||||
text=f"{self.comment_event_text} on the Jira ticket *{self.jira_ticket_key}*"
|
||||
),
|
||||
DividerBlock(),
|
||||
SectionBlock(
|
||||
fields=[
|
||||
MarkdownTextObject(
|
||||
text=f":face_with_monocle: *Author:*\n{self.author_jira_name}"
|
||||
),
|
||||
MarkdownTextObject(text=f":pencil2: *Comment:*\n{self.comment}"),
|
||||
MarkdownTextObject(
|
||||
text=f":jira_new: *Jira:*\n{RAID_JIRA_API_URL}/browse/{self.jira_ticket_key}"
|
||||
),
|
||||
]
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
class SlackMessageJiraClosedBlockedByCanBeClosed(SlackMessageSurface):
|
||||
"""Sent to the incident channel when a Jira 'Closed' webhook was received but the incident cannot be automatically closed (e.g. missing key events)."""
|
||||
|
||||
id = "raid_jira_closed_blocked"
|
||||
|
||||
def __init__(self, jira_ticket_key: str, reasons: list[tuple[str, str]]) -> None:
|
||||
self.jira_ticket_key = jira_ticket_key
|
||||
self.reasons = reasons
|
||||
super().__init__()
|
||||
|
||||
def get_text(self) -> str:
|
||||
return (
|
||||
f"Jira ticket {self.jira_ticket_key} was closed, "
|
||||
"but the incident could not be automatically closed."
|
||||
)
|
||||
|
||||
def get_blocks(self) -> list[Block]:
|
||||
reasons_text = "\n".join(f"• {reason}" for _, reason in self.reasons)
|
||||
return [
|
||||
SectionBlock(
|
||||
text=(
|
||||
f":jira_new: Jira ticket *{self.jira_ticket_key}* was closed, "
|
||||
"but the incident was *not* automatically closed."
|
||||
)
|
||||
),
|
||||
DividerBlock(),
|
||||
SectionBlock(
|
||||
text=f":warning: *The following must be resolved before closing:*\n{reasons_text}"
|
||||
),
|
||||
SectionBlock(
|
||||
text="Once resolved, close the incident using the _Close incident_ action in this channel."
|
||||
),
|
||||
]
|
||||
-175
@@ -1,175 +0,0 @@
|
||||
# Generated by Django 4.2.7 on 2023-12-04 10:46
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
replaces = [
|
||||
("raid", "0001_initial_squashed_0008_qualifierrotation"),
|
||||
("raid", "0009_alter_jiraticket_business_impact"),
|
||||
("raid", "0010_raidarea"),
|
||||
("raid", "0010_jiraticket_issue_type_jiraticket_project_key"),
|
||||
("raid", "0011_merge_20230408_1956"),
|
||||
("raid", "0012_jiraticketimpact_jiraticket_impacts"),
|
||||
("raid", "0013_alter_jiraticket_watchers"),
|
||||
("raid", "0014_alter_jiraticket_business_impact"),
|
||||
("raid", "0015_alter_jiraticket_assignee_alter_jiraticket_reporter_and_more"),
|
||||
("raid", "0016_remove_jiraticket_assignee_and_more"),
|
||||
("raid", "0017_alter_qualifierrotation_options_and_more"),
|
||||
("raid", "0018_alter_qualifier_jira_user_and_more"),
|
||||
]
|
||||
|
||||
dependencies = [
|
||||
("jira_app", "0005_alter_jiraissue_assignee"),
|
||||
("incidents", "0036_severity_enabled_create_severity_enabled_update_and_more"),
|
||||
(
|
||||
"incidents",
|
||||
"0039_impact_impacttype_incidentimpact_impact_impact_type_and_more",
|
||||
),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
("jira_app", "0001_initial"),
|
||||
("jira_app", "0003_jiraissue"),
|
||||
]
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="JiraTicket",
|
||||
fields=[
|
||||
(
|
||||
"jiraissue_ptr",
|
||||
models.OneToOneField(
|
||||
auto_created=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
parent_link=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
to="jira_app.jiraissue",
|
||||
),
|
||||
),
|
||||
(
|
||||
"business_impact",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
default="",
|
||||
help_text="It maps with customfield_10936",
|
||||
max_length=128,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Jira ticket",
|
||||
"verbose_name_plural": "Jira tickets",
|
||||
},
|
||||
bases=("jira_app.jiraissue",),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="RaidArea",
|
||||
fields=[
|
||||
("id", models.AutoField(primary_key=True, serialize=False)),
|
||||
("name", models.CharField(max_length=128)),
|
||||
(
|
||||
"area",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("Sellers", "Sellers"),
|
||||
("Internal", "Internal"),
|
||||
("Customers", "Customers"),
|
||||
],
|
||||
max_length=32,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"unique_together": {("name", "area")},
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="QualifierRotation",
|
||||
fields=[
|
||||
("id", models.AutoField(primary_key=True, serialize=False)),
|
||||
("day", models.DateField(unique=True)),
|
||||
(
|
||||
"protected",
|
||||
models.BooleanField(
|
||||
default=False,
|
||||
help_text="If checked, it is because this rotation was previously modified so this date won't be deleted and it can not be unprotected again from here.",
|
||||
),
|
||||
),
|
||||
(
|
||||
"jira_user",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="jira_app.jirauser",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Qualifiers rotation",
|
||||
"verbose_name_plural": "Qualifiers rotation",
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="Qualifier",
|
||||
fields=[
|
||||
("id", models.AutoField(primary_key=True, serialize=False)),
|
||||
(
|
||||
"jira_user",
|
||||
models.OneToOneField(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="jira_app.jirauser",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Qualifier",
|
||||
"verbose_name_plural": "Qualifiers",
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="JiraTicketImpact",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"impact",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="incidents.impact",
|
||||
),
|
||||
),
|
||||
(
|
||||
"jira_ticket",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="raid.jiraticket",
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="jiraticket",
|
||||
name="impacts",
|
||||
field=models.ManyToManyField(
|
||||
through="raid.JiraTicketImpact", to="incidents.impact"
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="jiraticket",
|
||||
name="incident",
|
||||
field=models.OneToOneField(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="jira_ticket",
|
||||
to="incidents.incident",
|
||||
),
|
||||
),
|
||||
]
|
||||
-36
@@ -1,36 +0,0 @@
|
||||
# Generated by Django 4.2.11 on 2024-05-03 09:13
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("raid", "0001_initial_oss"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="FeatureTeam",
|
||||
fields=[
|
||||
("id", models.AutoField(primary_key=True, serialize=False)),
|
||||
("name", models.CharField(max_length=80)),
|
||||
("jira_project_key", models.CharField(max_length=10, unique=True)),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Feature Team",
|
||||
"verbose_name_plural": "Feature Teams",
|
||||
"unique_together": {("name", "jira_project_key")},
|
||||
},
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="qualifierrotation",
|
||||
name="jira_user",
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name="Qualifier",
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name="QualifierRotation",
|
||||
),
|
||||
]
|
||||
-16
@@ -1,16 +0,0 @@
|
||||
# Generated by Django 4.2.23 on 2025-09-17 17:49
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("raid", "0002_featureteam_remove_qualifierrotation_jira_user_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.DeleteModel(
|
||||
name="RaidArea",
|
||||
),
|
||||
]
|
||||
Whitespace-only changes.
@@ -1,83 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
from django_stubs_ext.db.models import TypedModelMeta
|
||||
|
||||
from firefighter.incidents.models.incident import Incident
|
||||
from firefighter.jira_app.models import JiraIssue
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from firefighter.incidents.models.impact import Impact # noqa: F401
|
||||
|
||||
|
||||
class JiraTicket(JiraIssue):
|
||||
"""Jira ticket model."""
|
||||
|
||||
# XXX Incident/Impact should be split into new models
|
||||
incident = models.OneToOneField(
|
||||
Incident, on_delete=models.CASCADE, related_name="jira_ticket", null=True
|
||||
)
|
||||
impacts = models.ManyToManyField["Impact", "JiraTicketImpact"](
|
||||
"incidents.Impact", through="JiraTicketImpact"
|
||||
)
|
||||
|
||||
# TODO: priority
|
||||
# Jira Custom fields
|
||||
business_impact = models.CharField(
|
||||
max_length=128,
|
||||
help_text="It maps with customfield_10936",
|
||||
blank=True,
|
||||
null=True,
|
||||
default="",
|
||||
)
|
||||
|
||||
class Meta(TypedModelMeta):
|
||||
verbose_name = "Jira ticket"
|
||||
verbose_name_plural = "Jira tickets"
|
||||
|
||||
def get_absolute_url(self) -> str:
|
||||
return self.url
|
||||
|
||||
@property
|
||||
def url(self) -> str:
|
||||
return f"{settings.RAID_JIRA_API_URL}/browse/{self.key}"
|
||||
|
||||
|
||||
class JiraTicketImpact(models.Model):
|
||||
jira_ticket = models.ForeignKey(
|
||||
"JiraTicket", on_delete=models.CASCADE
|
||||
)
|
||||
impact = models.ForeignKey(
|
||||
"incidents.Impact", on_delete=models.CASCADE
|
||||
)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.jira_ticket.key}: {self.impact}"
|
||||
|
||||
|
||||
class FeatureTeam(models.Model):
|
||||
id = models.AutoField(primary_key=True)
|
||||
name = models.CharField(max_length=80)
|
||||
jira_project_key = models.CharField(
|
||||
max_length=10,
|
||||
unique=True,
|
||||
)
|
||||
|
||||
class Meta(TypedModelMeta):
|
||||
unique_together = ("name", "jira_project_key")
|
||||
verbose_name = "Feature Team"
|
||||
verbose_name_plural = "Feature Teams"
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.name
|
||||
|
||||
@property
|
||||
def get_team(self) -> str:
|
||||
return f"{self.name} {self.jira_project_key}"
|
||||
|
||||
@property
|
||||
def get_key(self) -> str:
|
||||
return f"{self.jira_project_key}"
|
||||
@@ -1,15 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from import_export import resources
|
||||
|
||||
from firefighter.raid.models import FeatureTeam
|
||||
|
||||
|
||||
class FeatureTeamResource(resources.ModelResource):
|
||||
class Meta:
|
||||
model = FeatureTeam
|
||||
skip_unchanged = True
|
||||
report_skipped = False
|
||||
fields = ("id", "name", "jira_project_key")
|
||||
import_order = ("name", "jira_project_key")
|
||||
export_order = ("name", "jira_project_key")
|
||||
-637
@@ -1,637 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
from rest_framework import serializers
|
||||
|
||||
from firefighter.incidents.enums import IncidentStatus
|
||||
from firefighter.incidents.models.priority import Priority
|
||||
from firefighter.incidents.models.user import User
|
||||
from firefighter.jira_app.client import (
|
||||
JiraAPIError,
|
||||
JiraUserNotFoundError,
|
||||
SlackNotificationError,
|
||||
)
|
||||
from firefighter.raid.client import client as jira_client
|
||||
from firefighter.raid.forms import (
|
||||
alert_slack_comment_ticket,
|
||||
alert_slack_new_jira_ticket,
|
||||
alert_slack_update_ticket,
|
||||
)
|
||||
from firefighter.raid.messages import SlackMessageJiraClosedBlockedByCanBeClosed
|
||||
from firefighter.raid.models import JiraTicket
|
||||
from firefighter.raid.utils import get_domain_from_email, normalize_cache_value
|
||||
from firefighter.slack.models.user import SlackUser
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from firefighter.incidents.models.incident import Incident
|
||||
from firefighter.jira_app.models import JiraUser
|
||||
|
||||
|
||||
JIRA_USER_IDS: dict[str, str] = settings.RAID_JIRA_USER_IDS
|
||||
JIRA_TO_IMPACT_STATUS_MAP: dict[str, IncidentStatus] = {
|
||||
"Incoming": IncidentStatus.OPEN,
|
||||
"Pending resolution": IncidentStatus.OPEN,
|
||||
"in progress": IncidentStatus.MITIGATING,
|
||||
"Reporter validation": IncidentStatus.MITIGATED,
|
||||
"Closed": IncidentStatus.CLOSED,
|
||||
}
|
||||
JIRA_TO_IMPACT_PRIORITY_MAP: dict[str, int] = {
|
||||
"1": 1,
|
||||
"2": 2,
|
||||
"3": 3,
|
||||
"4": 4,
|
||||
"5": 5,
|
||||
# Legacy Jira names still supported
|
||||
"Highest": 1,
|
||||
"High": 2,
|
||||
"Medium": 3,
|
||||
"Low": 4,
|
||||
"Lowest": 5,
|
||||
}
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class IgnoreEmptyStringListField(serializers.ListField):
|
||||
def to_internal_value(self, data: list[Any] | Any) -> list[str]:
|
||||
# Check if data is a list
|
||||
if not isinstance(data, list):
|
||||
msg = f'Expected a list but got type "{type(data).__name__}".'
|
||||
raise serializers.ValidationError(msg)
|
||||
|
||||
# Filter out any empty string values
|
||||
data = [item for item in data if item != ""]
|
||||
|
||||
return super().to_internal_value(data)
|
||||
|
||||
|
||||
def validate_no_spaces(value: str) -> None:
|
||||
"""Ensure the string does not contain spaces."""
|
||||
if " " in value:
|
||||
raise serializers.ValidationError("The string cannot contain spaces.")
|
||||
|
||||
|
||||
def get_reporter_user_from_email(reporter_email: str) -> tuple[User, JiraUser, str]:
|
||||
user_domain: str = get_domain_from_email(reporter_email)
|
||||
try:
|
||||
reporter_user: User = User.objects.get(email=reporter_email)
|
||||
reporter = jira_client.get_jira_user_from_user(reporter_user)
|
||||
except (User.DoesNotExist, JiraUserNotFoundError):
|
||||
reporter_user_tmp = SlackUser.objects.upsert_by_email(email=reporter_email)
|
||||
if reporter_user_tmp is not None:
|
||||
reporter_user = reporter_user_tmp
|
||||
|
||||
logger.warning(
|
||||
f"Reporter email {reporter_email} not found in Jira. Using default user."
|
||||
)
|
||||
|
||||
# Get default Jira user, depending on the domain
|
||||
# We might have accounts with Slack users, but not Jira users
|
||||
# In that case, we use the default Jira user for that domain
|
||||
match JIRA_USER_IDS.get(user_domain):
|
||||
case jira_user_id if jira_user_id is not None:
|
||||
reporter = jira_client.get_jira_user_from_jira_id(jira_user_id)
|
||||
reporter_user = (
|
||||
reporter.user if reporter_user_tmp is None else reporter_user_tmp
|
||||
)
|
||||
case _:
|
||||
reporter = jira_client.get_jira_user_from_jira_id(
|
||||
settings.RAID_DEFAULT_JIRA_QRAFT_USER_ID
|
||||
)
|
||||
reporter_user = (
|
||||
reporter.user if reporter_user_tmp is None else reporter_user_tmp
|
||||
)
|
||||
return reporter_user, reporter, user_domain
|
||||
|
||||
|
||||
class LandbotIssueRequestSerializer(serializers.ModelSerializer[JiraTicket]):
|
||||
id = serializers.IntegerField(read_only=True)
|
||||
key = serializers.CharField(read_only=True)
|
||||
seller_contract_id = serializers.CharField(
|
||||
max_length=128,
|
||||
write_only=True,
|
||||
allow_null=True,
|
||||
allow_blank=True,
|
||||
)
|
||||
zoho = serializers.CharField(
|
||||
max_length=256,
|
||||
write_only=True,
|
||||
allow_null=True,
|
||||
allow_blank=True,
|
||||
)
|
||||
zendesk = serializers.CharField(
|
||||
max_length=256,
|
||||
write_only=True,
|
||||
allow_null=True,
|
||||
allow_blank=True,
|
||||
required=False,
|
||||
)
|
||||
platform = serializers.ChoiceField(
|
||||
write_only=True, choices=["ES", "IT", "FR", "UK", "DE", "All", "Internal"]
|
||||
)
|
||||
reporter_email = serializers.EmailField(write_only=True)
|
||||
|
||||
labels = serializers.ListField(
|
||||
required=False,
|
||||
write_only=True,
|
||||
allow_null=True,
|
||||
default=list,
|
||||
child=serializers.CharField(
|
||||
max_length=128,
|
||||
allow_blank=False,
|
||||
allow_null=False,
|
||||
validators=[validate_no_spaces],
|
||||
),
|
||||
help_text="List of labels to be added to the ticket. Labels cannot contain spaces and must not exceed 255 characters.",
|
||||
)
|
||||
incident_category = serializers.CharField(
|
||||
max_length=128,
|
||||
write_only=True,
|
||||
allow_null=True,
|
||||
allow_blank=True,
|
||||
)
|
||||
suggested_team_routing = serializers.CharField(max_length=10, write_only=True)
|
||||
project = serializers.CharField(
|
||||
max_length=128,
|
||||
required=False,
|
||||
allow_blank=True,
|
||||
allow_null=True,
|
||||
default="SBI",
|
||||
)
|
||||
priority = serializers.IntegerField(
|
||||
min_value=1,
|
||||
max_value=5,
|
||||
write_only=True,
|
||||
allow_null=True,
|
||||
help_text="Priority level 1-5 (1=Critical, 2=High, 3=Medium, 4=Low, 5=Lowest)",
|
||||
)
|
||||
business_impact = serializers.ChoiceField(
|
||||
write_only=True, choices=["High", "Medium", "Low"], allow_null=True
|
||||
)
|
||||
environments = serializers.ListField(
|
||||
write_only=True,
|
||||
allow_null=True,
|
||||
default=["-"],
|
||||
child=serializers.ChoiceField(
|
||||
allow_null=True,
|
||||
allow_blank=True,
|
||||
choices=["INT", "STG", "PRD", "SUPPORT", "-"],
|
||||
),
|
||||
)
|
||||
attachments = serializers.CharField(
|
||||
required=False,
|
||||
allow_blank=True,
|
||||
allow_null=True,
|
||||
)
|
||||
issue_type = serializers.ChoiceField(
|
||||
required=False,
|
||||
allow_blank=True,
|
||||
allow_null=True,
|
||||
default="Incident",
|
||||
choices=[
|
||||
"Incident",
|
||||
"Service Request",
|
||||
"Documentation/Process Request",
|
||||
"Feature Request",
|
||||
],
|
||||
)
|
||||
|
||||
def validate_labels(self, value: list[str] | None) -> list[str]:
|
||||
"""Transform null labels to empty list."""
|
||||
if value is None:
|
||||
return []
|
||||
return value
|
||||
|
||||
def validate_environments(self, value: list[str] | None) -> list[str] | Any:
|
||||
if not value:
|
||||
return self.fields["environments"].default
|
||||
return value
|
||||
|
||||
def create(self, validated_data: dict[str, Any]) -> JiraTicket:
|
||||
reporter_email: str = validated_data["reporter_email"]
|
||||
|
||||
reporter_user, reporter, user_domain = get_reporter_user_from_email(
|
||||
reporter_email
|
||||
)
|
||||
user_in_description: str = (
|
||||
f"\nReporter email {reporter_email}\n"
|
||||
if "manomano" not in user_domain
|
||||
else ""
|
||||
)
|
||||
|
||||
issue = jira_client.create_issue(
|
||||
issuetype=validated_data["issue_type"],
|
||||
summary=validated_data["summary"],
|
||||
description=f"""{validated_data.get("description")}\n
|
||||
\n
|
||||
🤖 This ticket was created through the Incident Verification Bot https://landbot.pro/v3/H-1332620-8BF395J9STSHL7IN/index.html.\n
|
||||
\n
|
||||
{user_in_description}
|
||||
\n
|
||||
""",
|
||||
assignee=None,
|
||||
reporter=reporter.id,
|
||||
labels=validated_data["labels"],
|
||||
priority=validated_data["priority"],
|
||||
seller_contract_id=validated_data["seller_contract_id"],
|
||||
zoho_desk_ticket_id=validated_data["zoho"],
|
||||
zendesk_ticket_id=validated_data.get("zendesk"),
|
||||
platform=validated_data["platform"],
|
||||
incident_category=validated_data["incident_category"],
|
||||
business_impact=validated_data["business_impact"],
|
||||
environments=validated_data["environments"],
|
||||
suggested_team_routing=validated_data["suggested_team_routing"],
|
||||
project=validated_data["project"],
|
||||
)
|
||||
issue_id = issue.get("id")
|
||||
if issue_id is None:
|
||||
logger.error("Could not create Jira ticket")
|
||||
raise JiraAPIError("Could not create Jira ticket")
|
||||
if validated_data["attachments"] is not None:
|
||||
# Prepare attachments and double check we don't have any empty strings
|
||||
attachments: list[str] = [
|
||||
a
|
||||
for a in validated_data["attachments"]
|
||||
.replace("[", "")
|
||||
.replace("]", "")
|
||||
.replace("'", "")
|
||||
.split(", ")
|
||||
if a
|
||||
]
|
||||
jira_client.add_attachments_to_issue(issue_id, attachments)
|
||||
|
||||
jira_ticket = JiraTicket.objects.create(**issue)
|
||||
|
||||
# Send messages in the relevant Slack channels if needed and alert the reporter in DMs
|
||||
alert_slack_new_jira_ticket(
|
||||
jira_ticket, reporter_user=reporter_user, reporter_email=reporter_email
|
||||
)
|
||||
|
||||
return jira_ticket
|
||||
|
||||
class Meta:
|
||||
model = JiraTicket
|
||||
fields = [
|
||||
"summary",
|
||||
"description",
|
||||
"seller_contract_id",
|
||||
"zoho",
|
||||
"zendesk",
|
||||
"platform",
|
||||
"reporter_email",
|
||||
"incident_category",
|
||||
"labels",
|
||||
"environments",
|
||||
"issue_type",
|
||||
"business_impact",
|
||||
"priority",
|
||||
"id",
|
||||
"key",
|
||||
"attachments",
|
||||
"suggested_team_routing",
|
||||
"project",
|
||||
]
|
||||
|
||||
|
||||
class JiraWebhookUpdateSerializer(serializers.Serializer[Any]):
|
||||
issue = serializers.DictField()
|
||||
changelog = serializers.DictField()
|
||||
user = serializers.DictField()
|
||||
webhookEvent = serializers.ChoiceField( # noqa: N815
|
||||
write_only=True,
|
||||
choices=["jira:issue_updated"],
|
||||
)
|
||||
|
||||
def create(self, validated_data: dict[str, Any]) -> bool:
|
||||
changes = validated_data.get("changelog", {}).get("items") or []
|
||||
if not changes:
|
||||
logger.debug("Jira webhook had no changelog items; skipping.")
|
||||
return True
|
||||
|
||||
jira_ticket_key = validated_data["issue"].get("key")
|
||||
incident = (
|
||||
self._get_incident_from_jira_ticket(jira_ticket_key)
|
||||
if jira_ticket_key
|
||||
else None
|
||||
)
|
||||
if incident is None:
|
||||
# No linked incident: still emit Slack alerts for tracked fields (status/priority).
|
||||
for change_item in changes:
|
||||
field = (change_item.get("field") or "").lower()
|
||||
to_val = change_item.get("toString")
|
||||
from_val = change_item.get("fromString")
|
||||
is_status = field == "status"
|
||||
is_priority = (
|
||||
self._parse_priority_value(to_val) is not None
|
||||
or self._parse_priority_value(from_val) is not None
|
||||
)
|
||||
if not (is_status or is_priority):
|
||||
continue
|
||||
if not self._alert_slack_update(
|
||||
validated_data, jira_ticket_key, change_item
|
||||
):
|
||||
raise SlackNotificationError("Could not alert in Slack")
|
||||
return True
|
||||
|
||||
for change_item in changes:
|
||||
if not self._sync_jira_fields_to_incident(
|
||||
validated_data, jira_ticket_key, incident, change_item
|
||||
):
|
||||
continue
|
||||
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _alert_slack_update(
|
||||
validated_data: dict[str, Any],
|
||||
jira_ticket_key: str | None,
|
||||
change_item: dict[str, Any],
|
||||
) -> bool:
|
||||
ticket_id = validated_data["issue"].get("id")
|
||||
ticket_key = jira_ticket_key or ""
|
||||
jira_author_name = str(validated_data["user"].get("displayName") or "")
|
||||
jira_field_modified = str(change_item.get("field") or "")
|
||||
jira_field_from = str(change_item.get("fromString") or "")
|
||||
jira_field_to = str(change_item.get("toString") or "")
|
||||
|
||||
if ticket_id is None or ticket_key == "" or jira_field_modified == "":
|
||||
logger.error(
|
||||
"Missing required Jira changelog data: id=%s key=%s field=%s",
|
||||
ticket_id,
|
||||
jira_ticket_key,
|
||||
change_item.get("field"),
|
||||
)
|
||||
return False
|
||||
|
||||
status = alert_slack_update_ticket(
|
||||
jira_ticket_id=int(ticket_id),
|
||||
jira_ticket_key=ticket_key,
|
||||
jira_author_name=jira_author_name,
|
||||
jira_field_modified=jira_field_modified,
|
||||
jira_field_from=jira_field_from,
|
||||
jira_field_to=jira_field_to,
|
||||
)
|
||||
if status is not True:
|
||||
logger.error(
|
||||
"Could not alert in Slack for the update/s in the Jira ticket %s",
|
||||
jira_ticket_key or "unknown",
|
||||
)
|
||||
return False
|
||||
return True
|
||||
|
||||
def _sync_jira_fields_to_incident(
|
||||
self,
|
||||
validated_data: dict[str, Any],
|
||||
jira_ticket_key: str | None,
|
||||
incident: Incident,
|
||||
change_item: dict[str, Any],
|
||||
) -> bool:
|
||||
field = (change_item.get("field") or "").lower()
|
||||
|
||||
# Loop prevention: skip if this exact change was just sent Impact -> Jira
|
||||
if self._skip_due_to_recent_impact_change(jira_ticket_key, field, change_item):
|
||||
logger.debug(
|
||||
"Skipping Jira→Impact sync for %s on %s due to recent Impact change",
|
||||
field,
|
||||
jira_ticket_key,
|
||||
)
|
||||
return False
|
||||
|
||||
# Detect and apply status/priority updates only
|
||||
if field == "status":
|
||||
if not self._alert_slack_update(
|
||||
validated_data, jira_ticket_key, change_item
|
||||
):
|
||||
raise SlackNotificationError("Could not alert in Slack")
|
||||
return self._handle_status_update(validated_data, incident, change_item)
|
||||
|
||||
to_val = change_item.get("toString")
|
||||
from_val = change_item.get("fromString")
|
||||
if (
|
||||
self._parse_priority_value(to_val) is not None
|
||||
or self._parse_priority_value(from_val) is not None
|
||||
):
|
||||
if not self._alert_slack_update(
|
||||
validated_data, jira_ticket_key, change_item
|
||||
):
|
||||
raise SlackNotificationError("Could not alert in Slack")
|
||||
return self._handle_priority_update(validated_data, incident, change_item)
|
||||
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _get_incident_from_jira_ticket(jira_ticket_key: str) -> Incident | None:
|
||||
try:
|
||||
jira_ticket = JiraTicket.objects.select_related("incident").get(
|
||||
key=jira_ticket_key
|
||||
)
|
||||
except JiraTicket.DoesNotExist:
|
||||
logger.warning(
|
||||
"Received Jira webhook for %s but no JiraTicket found; skipping",
|
||||
jira_ticket_key,
|
||||
)
|
||||
return None
|
||||
|
||||
incident = getattr(jira_ticket, "incident", None)
|
||||
if incident is None:
|
||||
logger.warning(
|
||||
"Jira ticket %s not linked to an incident; skipping sync",
|
||||
jira_ticket_key,
|
||||
)
|
||||
return None
|
||||
|
||||
return incident
|
||||
|
||||
def _handle_status_update(
|
||||
self,
|
||||
validated_data: dict[str, Any],
|
||||
incident: Incident,
|
||||
change_item: dict[str, Any],
|
||||
) -> bool:
|
||||
jira_status_to = change_item.get("toString") or ""
|
||||
jira_status_from = change_item.get("fromString") or ""
|
||||
impact_status = JIRA_TO_IMPACT_STATUS_MAP.get(jira_status_to)
|
||||
if impact_status is None:
|
||||
logger.debug(
|
||||
"Jira status '%s' has no Impact mapping; skipping incident sync",
|
||||
jira_status_to,
|
||||
)
|
||||
return True
|
||||
|
||||
# Guard against unintentional closures triggered by non-standard Jira workflows.
|
||||
# If the target maps to CLOSED but the previous Jira status is not a known Impact
|
||||
# status (e.g. "Code Review" from a CI/CD automation), this is likely an intermediate
|
||||
# step and not a deliberate incident closure. Only allow close when the transition
|
||||
# comes from a recognised Impact status (e.g. "Reporter validation").
|
||||
if impact_status == IncidentStatus.CLOSED and jira_status_from not in JIRA_TO_IMPACT_STATUS_MAP:
|
||||
logger.warning(
|
||||
"Skipping Jira→Impact close for incident %s: transition from unknown Jira status '%s' to 'Closed'. "
|
||||
"This looks like an intermediate step in a non-standard Jira workflow (e.g. CI/CD automation). "
|
||||
"Close the ticket from a recognised status (e.g. 'Reporter validation') to trigger Impact closure.",
|
||||
incident.id,
|
||||
jira_status_from,
|
||||
)
|
||||
return True
|
||||
|
||||
if incident.status == impact_status:
|
||||
logger.debug(
|
||||
"Incident %s already at status %s from Jira webhook; no-op",
|
||||
incident.id,
|
||||
incident.status.label,
|
||||
)
|
||||
return True
|
||||
|
||||
# Before closing, verify all business rules are satisfied (key events, status, etc.).
|
||||
# If not, notify the incident channel and leave the incident open.
|
||||
if impact_status == IncidentStatus.CLOSED:
|
||||
can_close, reasons = incident.can_be_closed
|
||||
if not can_close:
|
||||
logger.warning(
|
||||
"Jira ticket closed but Impact incident %s cannot be closed yet: %s",
|
||||
incident.id,
|
||||
reasons,
|
||||
)
|
||||
jira_ticket_key = validated_data.get("issue", {}).get("key", "")
|
||||
if hasattr(incident, "conversation") and jira_ticket_key:
|
||||
try:
|
||||
incident.conversation.send_message_and_save(
|
||||
SlackMessageJiraClosedBlockedByCanBeClosed(
|
||||
jira_ticket_key=jira_ticket_key,
|
||||
reasons=reasons,
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Failed to notify incident channel for blocked close of incident %s",
|
||||
incident.id,
|
||||
)
|
||||
return True
|
||||
|
||||
incident.create_incident_update(
|
||||
created_by=None,
|
||||
status=impact_status,
|
||||
message=f"Status synced from Jira ({jira_status_to})",
|
||||
event_type="jira_status_sync",
|
||||
)
|
||||
return True
|
||||
|
||||
def _handle_priority_update(
|
||||
self,
|
||||
_validated_data: dict[str, Any],
|
||||
incident: Incident,
|
||||
change_item: dict[str, Any],
|
||||
) -> bool:
|
||||
jira_priority_to = change_item.get("toString") or ""
|
||||
|
||||
impact_priority_value = self._parse_priority_value(jira_priority_to)
|
||||
|
||||
if impact_priority_value is None:
|
||||
logger.debug(
|
||||
"Jira priority '%s' has no Impact mapping; skipping incident sync",
|
||||
jira_priority_to,
|
||||
)
|
||||
return True
|
||||
if incident.priority and incident.priority.value == impact_priority_value:
|
||||
logger.debug(
|
||||
"Incident %s already at priority %s from Jira webhook; no-op",
|
||||
incident.id,
|
||||
incident.priority.value,
|
||||
)
|
||||
return True
|
||||
|
||||
try:
|
||||
impact_priority = Priority.objects.get(value=impact_priority_value)
|
||||
except Priority.DoesNotExist:
|
||||
logger.warning(
|
||||
"No Impact priority with value %s; skipping Jira→Impact priority sync",
|
||||
impact_priority_value,
|
||||
)
|
||||
return True
|
||||
|
||||
incident.create_incident_update(
|
||||
created_by=None,
|
||||
priority_id=impact_priority.id,
|
||||
message=f"Priority synced from Jira ({jira_priority_to})",
|
||||
event_type="jira_priority_sync",
|
||||
)
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _skip_due_to_recent_impact_change(
|
||||
jira_ticket_key: str | None, field: str, change_item: dict[str, Any]
|
||||
) -> bool:
|
||||
if not jira_ticket_key:
|
||||
return False
|
||||
try:
|
||||
jira_ticket = JiraTicket.objects.select_related("incident").get(
|
||||
key=jira_ticket_key
|
||||
)
|
||||
except JiraTicket.DoesNotExist:
|
||||
return False
|
||||
|
||||
incident = getattr(jira_ticket, "incident", None)
|
||||
if not incident:
|
||||
return False
|
||||
|
||||
raw_value = change_item.get("toString")
|
||||
# Normalize value consistently with Impact→Jira writes
|
||||
if field == "status":
|
||||
value_for_cache = raw_value
|
||||
else:
|
||||
parsed = JiraWebhookUpdateSerializer._parse_priority_value(raw_value)
|
||||
value_for_cache = parsed if parsed is not None else raw_value
|
||||
|
||||
cache_key = f"sync:impact_to_jira:{incident.id}:{field}:{normalize_cache_value(value_for_cache)}"
|
||||
if cache.get(cache_key):
|
||||
cache.delete(cache_key)
|
||||
return True
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _parse_priority_value(raw: Any) -> int | None:
|
||||
"""Try to extract a priority value (1-5) from Jira changelog strings."""
|
||||
if raw is None:
|
||||
return None
|
||||
try:
|
||||
parsed = int(raw)
|
||||
if 1 <= parsed <= 5:
|
||||
return parsed
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
return JIRA_TO_IMPACT_PRIORITY_MAP.get(str(raw))
|
||||
|
||||
def update(self, instance: Any, validated_data: Any) -> Any:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class JiraWebhookCommentSerializer(serializers.Serializer[Any]):
|
||||
issue = serializers.DictField()
|
||||
comment = serializers.DictField()
|
||||
webhookEvent = serializers.ChoiceField( # noqa: N815
|
||||
write_only=True,
|
||||
choices=["comment_created", "comment_updated", "comment_deleted"],
|
||||
)
|
||||
|
||||
def create(self, validated_data: dict[str, Any]) -> bool:
|
||||
jira_ticket_key = validated_data["issue"].get("key")
|
||||
status = alert_slack_comment_ticket(
|
||||
webhook_event=validated_data["webhookEvent"],
|
||||
jira_ticket_id=validated_data["issue"].get("id"),
|
||||
jira_ticket_key=jira_ticket_key,
|
||||
author_jira_name=validated_data["comment"].get("author").get("displayName"),
|
||||
comment=validated_data["comment"].get("body"),
|
||||
)
|
||||
if status is not True:
|
||||
logger.error(
|
||||
f"Could not alert in Slack for created/modified comment in the Jira ticket {jira_ticket_key}"
|
||||
)
|
||||
raise SlackNotificationError("Could not alert in Slack")
|
||||
return True
|
||||
|
||||
def update(self, instance: Any, validated_data: Any) -> Any:
|
||||
raise NotImplementedError
|
||||
@@ -1,268 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from firefighter.jira_app.client import (
|
||||
JiraAPIError,
|
||||
JiraUserNotFoundError,
|
||||
)
|
||||
from firefighter.jira_app.models import JiraUser
|
||||
from firefighter.raid.client import client as jira_client
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from firefighter.incidents.models.user import User
|
||||
from firefighter.raid.types import JiraObject
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
RAID_DEFAULT_JIRA_QRAFT_USER_ID: str = settings.RAID_DEFAULT_JIRA_QRAFT_USER_ID
|
||||
error_jira_ticket_creation = "Could not create Jira ticket"
|
||||
|
||||
|
||||
def check_issue_id(issue: JiraObject, title: str, reporter: str) -> int | str:
|
||||
"""Check and return the issue ID from a JiraObject.
|
||||
|
||||
Raises JiraAPIError if the issue ID is missing or invalid.
|
||||
"""
|
||||
issue_id = issue.get("id")
|
||||
if issue_id is None:
|
||||
logger.error(
|
||||
f"Could not create Jira ticket for the incident {title} and the reporter {reporter}"
|
||||
)
|
||||
raise JiraAPIError(error_jira_ticket_creation)
|
||||
return issue_id
|
||||
|
||||
|
||||
def get_jira_user_from_user(user: User) -> JiraUser:
|
||||
"""Returns the JiraUser object for a given user, if it exists in DB or can be fetched on Jira API. Returns the default JiraUser if not found."""
|
||||
try:
|
||||
jira_user = jira_client.get_jira_user_from_user(user)
|
||||
except JiraAPIError:
|
||||
logger.exception(f"Could not find Jira user for {user.id}")
|
||||
|
||||
logger.warning(f"User {user.id} has no Jira user")
|
||||
try:
|
||||
jira_user = jira_client.get_jira_user_from_jira_id(
|
||||
RAID_DEFAULT_JIRA_QRAFT_USER_ID
|
||||
)
|
||||
except JiraUserNotFoundError:
|
||||
logger.exception(
|
||||
f"Could not find Jira user with account id {RAID_DEFAULT_JIRA_QRAFT_USER_ID}"
|
||||
)
|
||||
jira_user = JiraUser.objects.get(id=RAID_DEFAULT_JIRA_QRAFT_USER_ID)
|
||||
return jira_user
|
||||
|
||||
|
||||
def create_issue_feature_request(
|
||||
title: str,
|
||||
description: str,
|
||||
reporter: str,
|
||||
priority: int | None,
|
||||
labels: list[str] | None,
|
||||
platform: str,
|
||||
) -> JiraObject:
|
||||
"""Creates a Jira issue of type Feature Request.
|
||||
|
||||
Args:
|
||||
title (str): Summary of the issue
|
||||
description (str): Description of the issue
|
||||
reporter (str): Jira account id of the reporter
|
||||
priority (int): Priority of the issue
|
||||
labels (list[str]): Labels to add to the issue
|
||||
platform (str): Platform of the issue
|
||||
"""
|
||||
if labels is None:
|
||||
labels = [""]
|
||||
if "feature-request" not in labels:
|
||||
labels.append("feature-request")
|
||||
issue = jira_client.create_issue(
|
||||
issuetype="Feature Request",
|
||||
summary=title,
|
||||
description=description,
|
||||
assignee=None,
|
||||
reporter=reporter,
|
||||
priority=priority,
|
||||
labels=labels,
|
||||
platform=platform,
|
||||
)
|
||||
check_issue_id(issue, title=title, reporter=reporter)
|
||||
return issue
|
||||
|
||||
|
||||
def create_issue_documentation_request(
|
||||
title: str,
|
||||
description: str,
|
||||
reporter: str,
|
||||
priority: int | None,
|
||||
labels: list[str] | None,
|
||||
platform: str,
|
||||
) -> JiraObject:
|
||||
"""Creates a Jira issue of type Documentation/Process Request.
|
||||
|
||||
Args:
|
||||
title (str): Summary of the issue
|
||||
description (str): Description of the issue
|
||||
reporter (str): Jira account id of the reporter
|
||||
priority (int): Priority of the issue
|
||||
labels (list[str]): Labels to add to the issue
|
||||
platform (str): Platform of the issue
|
||||
"""
|
||||
if labels is None:
|
||||
labels = [""]
|
||||
if "documentation-request" not in labels:
|
||||
labels.append("documentation-request")
|
||||
issue = jira_client.create_issue(
|
||||
issuetype="Documentation/Process Request",
|
||||
summary=title,
|
||||
description=description,
|
||||
assignee=None,
|
||||
reporter=reporter,
|
||||
priority=priority,
|
||||
labels=labels,
|
||||
platform=platform,
|
||||
)
|
||||
check_issue_id(issue, title=title, reporter=reporter)
|
||||
return issue
|
||||
|
||||
|
||||
def create_issue_internal(
|
||||
title: str,
|
||||
description: str,
|
||||
reporter: str,
|
||||
priority: int | None,
|
||||
labels: list[str] | None,
|
||||
platform: str,
|
||||
business_impact: str | None,
|
||||
team_to_be_routed: str | None,
|
||||
incident_category: str | None,
|
||||
) -> JiraObject:
|
||||
"""Creates a Jira Incident Issue of type Internal.
|
||||
|
||||
Args:
|
||||
title (str): Summary of the issue
|
||||
description (str): Description of the issue
|
||||
reporter (str): Jira account id of the reporter
|
||||
priority (int): Priority of the issue
|
||||
labels (list[str]): Labels to add to the issue
|
||||
platform (str): Platform of the issue
|
||||
business_impact (str): Business impact of the issue
|
||||
team_to_be_routed (str): Team to be routed
|
||||
incident_category (str): Incident category of the issue
|
||||
"""
|
||||
issue = jira_client.create_issue(
|
||||
issuetype="Incident",
|
||||
summary=title,
|
||||
description=description,
|
||||
assignee=None,
|
||||
reporter=reporter,
|
||||
priority=priority,
|
||||
labels=labels,
|
||||
platform=platform,
|
||||
business_impact=business_impact,
|
||||
suggested_team_routing=team_to_be_routed,
|
||||
incident_category=incident_category,
|
||||
)
|
||||
check_issue_id(issue, title=title, reporter=reporter)
|
||||
return issue
|
||||
|
||||
|
||||
@dataclass
|
||||
class CustomerIssueData:
|
||||
"""Data container for customer issue creation parameters."""
|
||||
priority: int | None
|
||||
labels: list[str] | None
|
||||
platform: str
|
||||
business_impact: str | None
|
||||
team_to_be_routed: str | None
|
||||
area: str | None
|
||||
zendesk_ticket_id: str | None
|
||||
incident_category: str | None = None
|
||||
|
||||
|
||||
def create_issue_customer(
|
||||
title: str,
|
||||
description: str,
|
||||
reporter: str,
|
||||
issue_data: CustomerIssueData,
|
||||
) -> JiraObject:
|
||||
"""Creates a Jira Incident issue of type Customer.
|
||||
|
||||
Args:
|
||||
title (str): Summary of the issue
|
||||
description (str): Description of the issue
|
||||
reporter (str): Jira account id of the reporter
|
||||
issue_data (CustomerIssueData): Container with issue parameters
|
||||
"""
|
||||
issue = jira_client.create_issue(
|
||||
issuetype="Incident",
|
||||
summary=title,
|
||||
description=description,
|
||||
assignee=None,
|
||||
reporter=reporter,
|
||||
priority=issue_data.priority,
|
||||
labels=issue_data.labels,
|
||||
platform=issue_data.platform,
|
||||
business_impact=issue_data.business_impact,
|
||||
suggested_team_routing=issue_data.team_to_be_routed,
|
||||
zendesk_ticket_id=issue_data.zendesk_ticket_id,
|
||||
incident_category=issue_data.incident_category,
|
||||
)
|
||||
check_issue_id(issue, title=title, reporter=reporter)
|
||||
return issue
|
||||
|
||||
|
||||
def create_issue_seller( # noqa: PLR0913
|
||||
title: str,
|
||||
description: str,
|
||||
reporter: str,
|
||||
priority: int | None,
|
||||
labels: list[str] | None,
|
||||
platform: str,
|
||||
business_impact: str | None,
|
||||
team_to_be_routed: str | None,
|
||||
incident_category: str | None,
|
||||
seller_contract_id: str | None,
|
||||
is_key_account: bool | None,
|
||||
is_seller_in_golden_list: bool | None,
|
||||
zoho_desk_ticket_id: str | None,
|
||||
) -> JiraObject:
|
||||
"""Creates a Jira Incident issue of type Seller.
|
||||
|
||||
Args:
|
||||
title (str): Summary of the issue
|
||||
description (str): Description of the issue
|
||||
reporter (str): Jira account id of the reporter
|
||||
priority (int): Priority of the issue
|
||||
labels (list[str]): Labels to add to the issue
|
||||
platform (str): Platform of the issue
|
||||
business_impact (str): Business impact of the issue
|
||||
team_to_be_routed (str): Team to be routed
|
||||
incident_category (str): Incident category of the issue
|
||||
seller_contract_id (str): Seller contract id
|
||||
is_key_account (bool): Is key account
|
||||
is_seller_in_golden_list (bool): Is seller in golden list
|
||||
zoho_desk_ticket_id (str): Zoho desk ticket id
|
||||
"""
|
||||
issue = jira_client.create_issue(
|
||||
issuetype="Incident",
|
||||
summary=title,
|
||||
description=description,
|
||||
assignee=None,
|
||||
reporter=reporter,
|
||||
priority=priority,
|
||||
labels=labels,
|
||||
platform=platform,
|
||||
business_impact=business_impact,
|
||||
suggested_team_routing=team_to_be_routed,
|
||||
seller_contract_id=seller_contract_id,
|
||||
is_key_account=is_key_account,
|
||||
is_seller_in_golden_list=is_seller_in_golden_list,
|
||||
zoho_desk_ticket_id=zoho_desk_ticket_id,
|
||||
incident_category=incident_category,
|
||||
)
|
||||
check_issue_id(issue, title=title, reporter=reporter)
|
||||
return issue
|
||||
Whitespace-only changes.
-382
@@ -1,382 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
from django.db.models.signals import post_save
|
||||
from django.dispatch.dispatcher import receiver
|
||||
|
||||
from firefighter.incidents.enums import IncidentStatus
|
||||
from firefighter.incidents.models.incident import Incident
|
||||
from firefighter.incidents.signals import incident_updated
|
||||
from firefighter.raid.client import RAID_JIRA_WORKFLOW_NAME, client
|
||||
from firefighter.raid.utils import normalize_cache_value
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from firefighter.incidents.models.incident_update import IncidentUpdate
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
JIRA_SYNC_CACHE_TIMEOUT = getattr(settings, "JIRA_SYNC_CACHE_TIMEOUT", 60)
|
||||
|
||||
JIRA_STATUS_INCOMING = "Incoming"
|
||||
JIRA_STATUS_PENDING_RESOLUTION = "Pending resolution"
|
||||
JIRA_STATUS_IN_PROGRESS = "in progress"
|
||||
JIRA_STATUS_REPORTER_VALIDATION = "Reporter validation"
|
||||
JIRA_STATUS_CLOSED = "Closed"
|
||||
|
||||
IMPACT_TO_JIRA_STATUS_MAP: dict[IncidentStatus, str] = {
|
||||
IncidentStatus.OPEN: JIRA_STATUS_INCOMING,
|
||||
IncidentStatus.INVESTIGATING: JIRA_STATUS_IN_PROGRESS,
|
||||
IncidentStatus.MITIGATING: JIRA_STATUS_IN_PROGRESS,
|
||||
IncidentStatus.MITIGATED: JIRA_STATUS_REPORTER_VALIDATION,
|
||||
IncidentStatus.POST_MORTEM: JIRA_STATUS_REPORTER_VALIDATION,
|
||||
IncidentStatus.CLOSED: JIRA_STATUS_CLOSED,
|
||||
}
|
||||
|
||||
|
||||
def _set_impact_to_jira_cache(
|
||||
incident_id: Any, field: str, value: Any, timeout: int = JIRA_SYNC_CACHE_TIMEOUT
|
||||
) -> None:
|
||||
cache_key = (
|
||||
f"sync:impact_to_jira:{incident_id}:{field}:{normalize_cache_value(value)}"
|
||||
)
|
||||
cache.set(cache_key, value=True, timeout=timeout)
|
||||
|
||||
|
||||
@receiver(signal=incident_updated, sender="update_status")
|
||||
def incident_updated_close_ticket_when_mitigated_or_postmortem(
|
||||
sender: Any,
|
||||
incident: Incident,
|
||||
incident_update: IncidentUpdate,
|
||||
updated_fields: list[str],
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Close Jira incident ticket based on incident status and priority.
|
||||
|
||||
Closure logic:
|
||||
- P1/P2 (needs_postmortem): Close only when incident is CLOSED
|
||||
- P3+ (no postmortem): Close when incident is MITIGATED or CLOSED
|
||||
- POST_MORTEM status never closes the ticket (it remains open during PM phase)
|
||||
"""
|
||||
logger.debug(
|
||||
"incident_updated handler invoked for incident #%s with status %s; updated_fields=%s event_type=%s",
|
||||
getattr(incident, "id", "unknown"),
|
||||
incident_update.status,
|
||||
updated_fields,
|
||||
incident_update.event_type,
|
||||
)
|
||||
# Skip if this update was produced by Jira webhook sync to avoid redundant close calls
|
||||
if incident_update.event_type == "jira_status_sync":
|
||||
logger.debug(
|
||||
"Skipping Jira transition: incident #%s update came from Jira (event_type=jira_status_sync)",
|
||||
getattr(incident, "id", "unknown"),
|
||||
)
|
||||
return
|
||||
|
||||
if "_status" not in updated_fields:
|
||||
logger.debug(
|
||||
"Skipping Jira transition: incident #%s update lacks _status in updated_fields (%s)",
|
||||
getattr(incident, "id", "unknown"),
|
||||
updated_fields,
|
||||
)
|
||||
return
|
||||
|
||||
if not hasattr(incident, "jira_ticket") or incident.jira_ticket is None:
|
||||
logger.warning(
|
||||
"Trying to close Jira ticket for incident %s but no Jira ticket found",
|
||||
getattr(incident, "id", "unknown"),
|
||||
)
|
||||
return
|
||||
|
||||
# Special case: when Impact moves to INVESTIGATING or MITIGATING, Jira must go
|
||||
# through two steps: "Pending resolution" then "in progress". We also set
|
||||
# cache keys for both steps to prevent the Jira→Impact webhook from bouncing
|
||||
# Impact back to OPEN on the intermediate hop.
|
||||
if incident_update.status in (IncidentStatus.INVESTIGATING, IncidentStatus.MITIGATING):
|
||||
steps = (JIRA_STATUS_PENDING_RESOLUTION, JIRA_STATUS_IN_PROGRESS)
|
||||
incident_id = getattr(incident, "id", None)
|
||||
if incident_id is not None:
|
||||
for step in steps:
|
||||
_set_impact_to_jira_cache(incident_id, "status", step)
|
||||
|
||||
all_steps_succeeded = True
|
||||
for step in steps:
|
||||
try:
|
||||
logger.debug(
|
||||
"Transitioning Jira ticket %s via workflow %s to status %s (incident #%s, impact status %s)",
|
||||
incident.jira_ticket.id,
|
||||
RAID_JIRA_WORKFLOW_NAME,
|
||||
step,
|
||||
getattr(incident, "id", "unknown"),
|
||||
incident_update.status,
|
||||
)
|
||||
client.transition_issue_auto(
|
||||
incident.jira_ticket.id, step, RAID_JIRA_WORKFLOW_NAME
|
||||
)
|
||||
except Exception:
|
||||
all_steps_succeeded = False
|
||||
logger.exception(
|
||||
"Failed to transition Jira ticket %s to %s for incident %s",
|
||||
incident.jira_ticket.id,
|
||||
step,
|
||||
getattr(incident, "id", "unknown"),
|
||||
)
|
||||
if all_steps_succeeded:
|
||||
logger.info(
|
||||
"Transitioned Jira ticket %s through Pending resolution -> in progress from Impact status %s",
|
||||
incident.jira_ticket.id,
|
||||
incident_update.status.label if incident_update.status else "Unknown",
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"At least one Jira transition failed while moving ticket %s to MITIGATING (incident #%s)",
|
||||
incident.jira_ticket.id,
|
||||
getattr(incident, "id", "unknown"),
|
||||
)
|
||||
return
|
||||
|
||||
# Decide target Jira status based on Impact status and postmortem requirement.
|
||||
# P3+ (no postmortem): close Jira when Impact reaches MITIGATED or CLOSED.
|
||||
# P1/P2 (needs_postmortem): close Jira only when Impact reaches CLOSED.
|
||||
incident_status = incident_update.status
|
||||
if incident_status is None:
|
||||
logger.info(
|
||||
"Skipping Jira transition: incident #%s status is None",
|
||||
getattr(incident, "id", "unknown"),
|
||||
)
|
||||
return
|
||||
|
||||
target_jira_status: str | None = (
|
||||
JIRA_STATUS_CLOSED
|
||||
if incident_status == IncidentStatus.CLOSED
|
||||
else IMPACT_TO_JIRA_STATUS_MAP.get(incident_status)
|
||||
)
|
||||
|
||||
if target_jira_status is None:
|
||||
logger.info(
|
||||
"Skipping Jira transition: no Jira status mapping for Impact status %s (incident #%s)",
|
||||
incident_update.status,
|
||||
getattr(incident, "id", "unknown"),
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
incident_id = getattr(incident, "id", None)
|
||||
if incident_id is not None:
|
||||
_set_impact_to_jira_cache(incident_id, "status", target_jira_status)
|
||||
logger.debug(
|
||||
"Transitioning Jira ticket %s via workflow %s to status %s (incident #%s, impact status %s)",
|
||||
incident.jira_ticket.id,
|
||||
RAID_JIRA_WORKFLOW_NAME,
|
||||
target_jira_status,
|
||||
getattr(incident, "id", "unknown"),
|
||||
incident_update.status,
|
||||
)
|
||||
client.transition_issue_auto(
|
||||
incident.jira_ticket.id, target_jira_status, RAID_JIRA_WORKFLOW_NAME
|
||||
)
|
||||
logger.info(
|
||||
"Transitioned Jira ticket %s to %s from Impact status %s",
|
||||
incident.jira_ticket.id,
|
||||
target_jira_status,
|
||||
incident_update.status.label if incident_update.status else "Unknown",
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Failed to transition Jira ticket %s to %s for incident %s",
|
||||
incident.jira_ticket.id,
|
||||
target_jira_status,
|
||||
getattr(incident, "id", "unknown"),
|
||||
)
|
||||
|
||||
|
||||
# Listen to all incident_updated signals so both UI (update_status) and API/admin paths trigger
|
||||
@receiver(signal=incident_updated)
|
||||
def incident_updated_sync_priority_to_jira(
|
||||
sender: Any,
|
||||
incident: Incident,
|
||||
incident_update: IncidentUpdate,
|
||||
updated_fields: list[str],
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Push Impact priority changes to Jira custom priority field (customfield_11064).
|
||||
Skips if change originated from Jira (event_type='jira_priority_sync') to avoid loops.
|
||||
"""
|
||||
logger.debug(
|
||||
"Priority sync handler invoked: incident #%s sender=%s updated_fields=%s event_type=%s",
|
||||
getattr(incident, "id", "unknown"),
|
||||
sender,
|
||||
updated_fields,
|
||||
incident_update.event_type,
|
||||
)
|
||||
|
||||
if incident_update.event_type == "jira_priority_sync":
|
||||
logger.debug(
|
||||
"Skipping Jira priority sync: incident #%s update came from Jira (event_type=jira_priority_sync)",
|
||||
getattr(incident, "id", "unknown"),
|
||||
)
|
||||
return
|
||||
|
||||
if "priority_id" not in updated_fields:
|
||||
logger.debug(
|
||||
"Skipping Jira priority sync: incident #%s update lacks priority_id in updated_fields (%s) sender=%s",
|
||||
getattr(incident, "id", "unknown"),
|
||||
updated_fields,
|
||||
sender,
|
||||
)
|
||||
return
|
||||
|
||||
if not hasattr(incident, "jira_ticket") or incident.jira_ticket is None:
|
||||
logger.debug(
|
||||
"Skipping Jira priority sync: incident #%s has no Jira ticket",
|
||||
getattr(incident, "id", "unknown"),
|
||||
)
|
||||
return
|
||||
|
||||
if not incident.priority:
|
||||
logger.debug(
|
||||
"Skipping Jira priority sync: incident #%s priority is missing",
|
||||
getattr(incident, "id", "unknown"),
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
incident_id = getattr(incident, "id", None)
|
||||
if incident_id is not None:
|
||||
_set_impact_to_jira_cache(incident_id, "priority", incident.priority.value)
|
||||
client.update_issue_fields(
|
||||
incident.jira_ticket.id,
|
||||
customfield_11064={"value": str(incident.priority.value)},
|
||||
)
|
||||
logger.info(
|
||||
"Synced priority %s to Jira ticket %s (customfield_11064) for incident #%s",
|
||||
incident.priority.value,
|
||||
incident.jira_ticket.id,
|
||||
getattr(incident, "id", "unknown"),
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Failed to sync priority %s to Jira ticket %s for incident %s",
|
||||
incident.priority.value,
|
||||
incident.jira_ticket.id,
|
||||
getattr(incident, "id", "unknown"),
|
||||
)
|
||||
|
||||
|
||||
# Fallback: if an Incident save bypasses incident_updated (e.g., admin inline), push priority anyway.
|
||||
@receiver(post_save, sender=Incident)
|
||||
def incident_priority_post_save_fallback(
|
||||
sender: Any,
|
||||
instance: Incident,
|
||||
*,
|
||||
created: bool,
|
||||
update_fields: set[str] | None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Fallback to push priority to Jira when Incident saves with priority_id in update_fields
|
||||
but no incident_updated signal fired (e.g., admin edits). Skips when marked to avoid loops.
|
||||
"""
|
||||
if created:
|
||||
return
|
||||
if update_fields and "priority_id" not in update_fields:
|
||||
return
|
||||
if getattr(instance, "_skip_priority_sync", False):
|
||||
logger.debug(
|
||||
"Skipping post_save priority sync for incident #%s due to skip flag",
|
||||
getattr(instance, "id", "unknown"),
|
||||
)
|
||||
return
|
||||
if not hasattr(instance, "jira_ticket") or instance.jira_ticket is None:
|
||||
logger.debug(
|
||||
"Skipping post_save priority sync: incident #%s has no Jira ticket",
|
||||
getattr(instance, "id", "unknown"),
|
||||
)
|
||||
return
|
||||
if not instance.priority:
|
||||
logger.debug(
|
||||
"Skipping post_save priority sync: incident #%s priority missing",
|
||||
getattr(instance, "id", "unknown"),
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
_set_impact_to_jira_cache(instance.id, "priority", instance.priority.value)
|
||||
client.update_issue_fields(
|
||||
instance.jira_ticket.id,
|
||||
customfield_11064={"value": str(instance.priority.value)},
|
||||
)
|
||||
logger.info(
|
||||
"Post-save synced priority %s to Jira ticket %s (customfield_11064) for incident #%s",
|
||||
instance.priority.value,
|
||||
instance.jira_ticket.id,
|
||||
getattr(instance, "id", "unknown"),
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Failed post-save priority sync %s to Jira ticket %s for incident %s",
|
||||
instance.priority.value,
|
||||
instance.jira_ticket.id,
|
||||
getattr(instance, "id", "unknown"),
|
||||
)
|
||||
|
||||
|
||||
@receiver(post_save, sender=Incident)
|
||||
def incident_status_post_save_fallback(
|
||||
sender: Any,
|
||||
instance: Incident,
|
||||
*,
|
||||
created: bool,
|
||||
update_fields: set[str] | None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Fallback to push status to Jira when Incident saves with status in update_fields
|
||||
but no incident_updated signal fired (e.g., admin edits). Skips when marked to avoid loops.
|
||||
"""
|
||||
if created:
|
||||
return
|
||||
if (
|
||||
update_fields
|
||||
and "_status" not in update_fields
|
||||
and "status" not in update_fields
|
||||
):
|
||||
return
|
||||
if getattr(instance, "_skip_status_sync", False):
|
||||
logger.debug(
|
||||
"Skipping post_save status sync for incident #%s due to skip flag",
|
||||
getattr(instance, "id", "unknown"),
|
||||
)
|
||||
return
|
||||
if not hasattr(instance, "jira_ticket") or instance.jira_ticket is None:
|
||||
logger.debug(
|
||||
"Skipping post_save status sync: incident #%s has no Jira ticket",
|
||||
getattr(instance, "id", "unknown"),
|
||||
)
|
||||
return
|
||||
target_jira_status = IMPACT_TO_JIRA_STATUS_MAP.get(instance.status)
|
||||
if target_jira_status is None:
|
||||
logger.debug(
|
||||
"Skipping post_save status sync: no Jira mapping for status %s (incident #%s)",
|
||||
instance.status,
|
||||
getattr(instance, "id", "unknown"),
|
||||
)
|
||||
return
|
||||
try:
|
||||
_set_impact_to_jira_cache(instance.id, "status", target_jira_status)
|
||||
client.transition_issue_auto(
|
||||
instance.jira_ticket.id, target_jira_status, RAID_JIRA_WORKFLOW_NAME
|
||||
)
|
||||
logger.info(
|
||||
"Post-save synced status %s to Jira ticket %s for incident #%s",
|
||||
instance.status,
|
||||
instance.jira_ticket.id,
|
||||
getattr(instance, "id", "unknown"),
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Failed post-save status sync %s to Jira ticket %s for incident %s",
|
||||
instance.status,
|
||||
instance.jira_ticket.id,
|
||||
getattr(instance, "id", "unknown"),
|
||||
)
|
||||
-1
@@ -1 +0,0 @@
|
||||
from __future__ import annotations
|
||||
@@ -1,23 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import NotRequired, TypedDict
|
||||
|
||||
_JiraId = int | str
|
||||
_Key = str
|
||||
_AssigneeId = str
|
||||
_ReporterId = str
|
||||
_Description = str
|
||||
_Summary = str
|
||||
_BusinessImpact = str
|
||||
|
||||
|
||||
class JiraObject(TypedDict):
|
||||
id: NotRequired[_JiraId]
|
||||
key: NotRequired[_Key]
|
||||
assignee_id: _AssigneeId
|
||||
reporter_id: _ReporterId
|
||||
description: _Description
|
||||
summary: _Summary
|
||||
issue_type: str
|
||||
project_key: str
|
||||
business_impact: _BusinessImpact
|
||||
@@ -1,33 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from django.apps import apps
|
||||
from django.urls import path
|
||||
|
||||
from firefighter.api.urls import urlpatterns as api_urlspatterns
|
||||
from firefighter.raid import views
|
||||
|
||||
# Patch `api` app router, if installed
|
||||
if apps.is_installed("firefighter.api"):
|
||||
api_urlspatterns.append(
|
||||
path(
|
||||
"raid/jira_bot",
|
||||
views.CreateJiraBotView.as_view(), # pyright: ignore[reportGeneralTypeIssues]
|
||||
name="jira_bot",
|
||||
),
|
||||
)
|
||||
|
||||
api_urlspatterns.append(
|
||||
path(
|
||||
"raid/jira_comment",
|
||||
views.JiraCommentAlertView.as_view(), # pyright: ignore[reportGeneralTypeIssues]
|
||||
name="jira_comment",
|
||||
),
|
||||
)
|
||||
|
||||
api_urlspatterns.append(
|
||||
path(
|
||||
"raid/jira_update",
|
||||
views.JiraUpdateAlertView.as_view(), # pyright: ignore[reportGeneralTypeIssues]
|
||||
name="jira_update",
|
||||
),
|
||||
)
|
||||
@@ -1,52 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from functools import cache
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@cache
|
||||
def get_domain_from_email(email: str) -> str:
|
||||
"""Returns the domain from an email address.
|
||||
|
||||
Removes any subdomain(s) and the @, applies .lower().
|
||||
|
||||
Args:
|
||||
email: The email address to extract the domain from.
|
||||
|
||||
Returns:
|
||||
The domain part of the email address.
|
||||
|
||||
Raises:
|
||||
ValueError: If the email is not well-formed.
|
||||
|
||||
Examples:
|
||||
- `john.doe@example.com` => `example.com`
|
||||
- `alice.bob@test.example.ORG` => `example.org`
|
||||
- `webmaster@localhost` => `localhost`
|
||||
"""
|
||||
# If there is not exactly one @, the email is invalid
|
||||
if email.count("@") != 1:
|
||||
msg = f"Invalid email: {email}"
|
||||
raise ValueError(msg)
|
||||
domain = email.rsplit("@", maxsplit=1)[-1]
|
||||
if not domain:
|
||||
msg = f"Invalid email: {email}"
|
||||
raise ValueError(msg)
|
||||
domain_parts = domain.split(".")
|
||||
|
||||
return (".".join(domain_parts[-2:]) if len(domain_parts) > 2 else domain).lower()
|
||||
|
||||
|
||||
def normalize_cache_value(value: Any) -> str:
|
||||
"""Normalize cache values for loop-prevention keys."""
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, str):
|
||||
return value.strip().lower()
|
||||
try:
|
||||
return str(int(value))
|
||||
except (TypeError, ValueError):
|
||||
return str(value).strip().lower()
|
||||
-129
@@ -1,129 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any, Final, Never
|
||||
|
||||
from django.conf import settings
|
||||
from drf_spectacular.utils import OpenApiExample, extend_schema
|
||||
from rest_framework import generics, mixins, permissions, status
|
||||
from rest_framework.renderers import JSONRenderer
|
||||
from rest_framework.response import Response
|
||||
|
||||
from firefighter.raid.models import JiraTicket
|
||||
from firefighter.raid.serializers import (
|
||||
JiraWebhookCommentSerializer,
|
||||
JiraWebhookUpdateSerializer,
|
||||
LandbotIssueRequestSerializer,
|
||||
)
|
||||
|
||||
RAID_DEFAULT_JIRA_QRAFT_USER_ID: Final[str] = settings.RAID_DEFAULT_JIRA_QRAFT_USER_ID
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
if TYPE_CHECKING:
|
||||
from rest_framework.request import Request
|
||||
|
||||
|
||||
@extend_schema(
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"Create an issue",
|
||||
summary="Create an issue",
|
||||
description="Example of a working request coming from landbot. be careful, as you probably don't have a john.doe user and thus may fallback to the default user.",
|
||||
value={
|
||||
"summary": "Swagger test",
|
||||
"description": "Description test where you want to depict your issue",
|
||||
"seller_contract_id": "12345678",
|
||||
"zoho": "https://crmplus.zoho.eu/mycrmlink/index.do/cxapp/agent/mycompany/all/tickets/details/123456789",
|
||||
"zendesk": "12345",
|
||||
"platform": "FR",
|
||||
"reporter_email": "john.doe@mycompany.com",
|
||||
"incident_category": "Payment Processing",
|
||||
"project": "SBI",
|
||||
"labels": ["originBot", "ProductsMerge"],
|
||||
"environments": ["PRD", "STG"],
|
||||
"issue_type": "Incident",
|
||||
"business_impact": "High",
|
||||
"priority": 4,
|
||||
"attachments": [
|
||||
"https://storage.googleapis.com/media.landbot.io/123456/customers/123456789/ABCDEFGHIJKLMNOPQRSTUVWXYZ123456.png",
|
||||
"https://storage.googleapis.com/media.landbot.io/123456/customers/123456780/ABCDEFGHIJKLMNOPQRSTUVWXYZ123450.png",
|
||||
],
|
||||
},
|
||||
request_only=True, # signal that example only applies to requests
|
||||
response_only=False, # signal that example only applies to responses
|
||||
),
|
||||
OpenApiExample(
|
||||
"Create incident response",
|
||||
status_codes=["201"],
|
||||
value={"https://mycompany.atlassian.net/browse/1234567"},
|
||||
request_only=False,
|
||||
response_only=True,
|
||||
),
|
||||
]
|
||||
)
|
||||
class CreateJiraBotView(
|
||||
mixins.CreateModelMixin,
|
||||
generics.GenericAPIView[JiraTicket],
|
||||
):
|
||||
queryset = JiraTicket.objects.all().select_related(
|
||||
"assignee",
|
||||
"reporter",
|
||||
"incident",
|
||||
"jiraissue_ptr",
|
||||
)
|
||||
serializer_class = LandbotIssueRequestSerializer
|
||||
permission_classes = [permissions.AllowAny]
|
||||
renderer_classes = [JSONRenderer]
|
||||
|
||||
def post(self, request: Request, *args: Never, **kwargs: Never) -> Response:
|
||||
"""Allow to create a Jira ticket through Landbot.
|
||||
Requires a valid Bearer token, that you can create in the back-office if you have the right permissions.
|
||||
"""
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
serializer.save()
|
||||
headers = self.get_success_headers(serializer.data)
|
||||
return Response(
|
||||
serializer.data.get("key"), status=status.HTTP_201_CREATED, headers=headers
|
||||
)
|
||||
|
||||
|
||||
class JiraUpdateAlertView(
|
||||
generics.CreateAPIView[Any],
|
||||
):
|
||||
serializer_class = JiraWebhookUpdateSerializer
|
||||
# XXX: Work on webhook token for authentication_classes
|
||||
authentication_classes = []
|
||||
permission_classes = [permissions.AllowAny]
|
||||
renderer_classes = [JSONRenderer]
|
||||
|
||||
def post(self, request: Request, *args: Never, **kwargs: Never) -> Response:
|
||||
"""Allow to send a message in Slack when some fields ("Priority", "project", "description", "status") of a Jira ticket are updated.
|
||||
Requires a valid Bearer token, that you can create in the back-office if you have the right permissions.
|
||||
"""
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
serializer.save()
|
||||
|
||||
return Response()
|
||||
|
||||
|
||||
class JiraCommentAlertView(
|
||||
generics.CreateAPIView[Any],
|
||||
):
|
||||
serializer_class = JiraWebhookCommentSerializer
|
||||
# XXX: Work on webhook token for authentication_classes
|
||||
authentication_classes = []
|
||||
permission_classes = [permissions.AllowAny]
|
||||
renderer_classes = [JSONRenderer]
|
||||
|
||||
def post(self, request: Request, *args: Never, **kwargs: Never) -> Response:
|
||||
"""Allow to send a message in Slack when a comment in a Jira ticket is created or modified.
|
||||
Requires a valid Bearer token, that you can create in the back-office if you have the right permissions.
|
||||
"""
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
serializer.save()
|
||||
|
||||
return Response()
|
||||
@@ -0,0 +1 @@
|
||||
{"ghsa": "GHSA-gfhv-vqv2-4544", "repo": "jelmer/dulwich", "cwes": ["CWE-22"], "summary": "submodule: Reject unsafe submodule paths in submodule_update", "fix_commit": "1ca18147a1d03b61c2ae203c46bf0b2a2f5dd421", "parent_commit": "3559ef15c1e2a8d2a56c98f36b53b29c5d60b9fd", "vuln_files": ["dulwich/porcelain/submodule.py", "dulwich/index.py"], "vuln_lines": {"dulwich/porcelain/submodule.py": [25, 89, 125, 140, 146, 233], "dulwich/index.py": [62, 2075]}, "source_files": 84, "scope_dirs": ["dulwich"], "source_note": "Subset: only the dulwich/ package (containing both vuln files) was copied into source/, not the full repo."}
|
||||
-64
@@ -1,64 +0,0 @@
|
||||
/** @file
|
||||
|
||||
Copyright (c) 2004 - 2008, Intel Corporation. All rights reserved.<BR>
|
||||
This program and the accompanying materials
|
||||
are licensed and made available under the terms and conditions of the BSD License
|
||||
which accompanies this distribution. The full text of the license may be found at
|
||||
http://opensource.org/licenses/bsd-license.php
|
||||
|
||||
THE PROGRAM IS DISTRIBUTED UNDER THE BSD LICENSE ON AN "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR REPRESENTATIONS OF ANY KIND, EITHER EXPRESS OR IMPLIED.
|
||||
|
||||
Module Name:
|
||||
|
||||
Compress.h
|
||||
|
||||
Abstract:
|
||||
|
||||
Header file for compression routine.
|
||||
Providing both EFI and Tiano Compress algorithms.
|
||||
|
||||
**/
|
||||
|
||||
#ifndef _EFI_COMPRESS_H_
|
||||
#define _EFI_COMPRESS_H_
|
||||
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
//#include "CommonLib.h"
|
||||
#include "BaseTypes.h"
|
||||
|
||||
/*++
|
||||
|
||||
Routine Description:
|
||||
|
||||
Tiano compression routine.
|
||||
|
||||
--*/
|
||||
EFI_STATUS
|
||||
TianoCompress (
|
||||
IN UINT8 *SrcBuffer,
|
||||
IN size_t SrcSize,
|
||||
IN UINT8 *DstBuffer,
|
||||
IN OUT size_t *DstSize
|
||||
)
|
||||
;
|
||||
|
||||
/*++
|
||||
|
||||
Routine Description:
|
||||
|
||||
Efi compression routine.
|
||||
|
||||
--*/
|
||||
EFI_STATUS
|
||||
EfiCompress (
|
||||
IN UINT8 *SrcBuffer,
|
||||
IN size_t SrcSize,
|
||||
IN UINT8 *DstBuffer,
|
||||
IN OUT size_t *DstSize
|
||||
)
|
||||
;
|
||||
|
||||
#endif
|
||||
-945
@@ -1,945 +0,0 @@
|
||||
// This is an open source non-commercial project. Dear PVS-Studio, please check it.
|
||||
// PVS-Studio Static Code Analyzer for C, C++ and C#: http://www.viva64.com
|
||||
/** @file
|
||||
|
||||
Copyright (c) 2004 - 2008, Intel Corporation. All rights reserved.<BR>
|
||||
This program and the accompanying materials
|
||||
are licensed and made available under the terms and conditions of the BSD License
|
||||
which accompanies this distribution. The full text of the license may be found at
|
||||
http://opensource.org/licenses/bsd-license.php
|
||||
|
||||
THE PROGRAM IS DISTRIBUTED UNDER THE BSD LICENSE ON AN "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR REPRESENTATIONS OF ANY KIND, EITHER EXPRESS OR IMPLIED.
|
||||
|
||||
Module Name:
|
||||
|
||||
Decompress.c
|
||||
|
||||
Abstract:
|
||||
|
||||
Decompressor. Algorithm Ported from OPSD code (Decomp.asm)
|
||||
for Efi and Tiano compress algorithm.
|
||||
|
||||
--*/
|
||||
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include "Decompress.h"
|
||||
|
||||
//
|
||||
// Decompression algorithm begins here
|
||||
//
|
||||
#define BITBUFSIZ 32
|
||||
#define MAXMATCH 256
|
||||
#define THRESHOLD 3
|
||||
#define CODE_BIT 16
|
||||
#define BAD_TABLE - 1
|
||||
#define MPTTABLESIZE 256u
|
||||
#define MCTABLESIZE 4096u
|
||||
|
||||
//
|
||||
// C: Char&Len Set; P: Position Set; T: exTra Set
|
||||
//
|
||||
#define NC (0xff + MAXMATCH + 2 - THRESHOLD)
|
||||
#define CBIT 9
|
||||
#define EFIPBIT 4
|
||||
#define MAXPBIT 5
|
||||
#define TBIT 5
|
||||
#define MAXNP ((1U << MAXPBIT) - 1)
|
||||
#define NT (CODE_BIT + 3)
|
||||
#if NT > MAXNP
|
||||
#define NPT NT
|
||||
#else
|
||||
#define NPT MAXNP
|
||||
#endif
|
||||
|
||||
|
||||
typedef struct {
|
||||
UINT8 *mSrcBase; // Starting address of compressed data
|
||||
UINT8 *mDstBase; // Starting address of decompressed data
|
||||
size_t mOutBuf;
|
||||
size_t mInBuf;
|
||||
|
||||
UINT16 mBitCount;
|
||||
size_t mBitBuf;
|
||||
size_t mSubBitBuf;
|
||||
UINT16 mBlockSize;
|
||||
size_t mCompSize;
|
||||
size_t mOrigSize;
|
||||
|
||||
UINT16 mBadTableFlag;
|
||||
UINT16 mBadAlgorithm;
|
||||
|
||||
UINT16 mLeft[2 * NC - 1];
|
||||
UINT16 mRight[2 * NC - 1];
|
||||
UINT8 mCLen[NC];
|
||||
UINT8 mPTLen[NPT];
|
||||
UINT16 mCTable[MCTABLESIZE];
|
||||
UINT16 mPTTable[MPTTABLESIZE];
|
||||
|
||||
} SCRATCH_DATA;
|
||||
|
||||
STATIC UINT16 mPbit = EFIPBIT;
|
||||
|
||||
STATIC
|
||||
VOID
|
||||
FillBuf (
|
||||
IN SCRATCH_DATA *Sd,
|
||||
IN UINT16 NumOfBits
|
||||
)
|
||||
/*++
|
||||
|
||||
Routine Description:
|
||||
|
||||
Shift mBitBuf NumOfBits left. Read in NumOfBits of bits from source.
|
||||
|
||||
Arguments:
|
||||
|
||||
Sd - The global scratch data
|
||||
NumOfBit - The number of bits to shift and read.
|
||||
|
||||
Returns: (VOID)
|
||||
|
||||
--*/
|
||||
{
|
||||
Sd->mBitBuf = (Sd->mBitBuf << NumOfBits) & 0xFFFFFFFFLL;
|
||||
|
||||
while (NumOfBits > Sd->mBitCount) {
|
||||
|
||||
Sd->mBitBuf |= Sd->mSubBitBuf << (NumOfBits = (UINT16) (NumOfBits - Sd->mBitCount));
|
||||
|
||||
if (Sd->mCompSize > 0) {
|
||||
//
|
||||
// Get 1 byte into SubBitBuf
|
||||
//
|
||||
Sd->mCompSize--;
|
||||
Sd->mSubBitBuf = 0;
|
||||
Sd->mSubBitBuf = Sd->mSrcBase[Sd->mInBuf++];
|
||||
Sd->mBitCount = 8;
|
||||
|
||||
} else {
|
||||
//
|
||||
// No more bits from the source, just pad zero bit.
|
||||
//
|
||||
Sd->mSubBitBuf = 0;
|
||||
Sd->mBitCount = 8;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
Sd->mBitCount = (UINT16) (Sd->mBitCount - NumOfBits);
|
||||
Sd->mBitBuf |= Sd->mSubBitBuf >> Sd->mBitCount;
|
||||
}
|
||||
|
||||
STATIC
|
||||
size_t
|
||||
GetBits (
|
||||
IN SCRATCH_DATA *Sd,
|
||||
IN UINT16 NumOfBits
|
||||
)
|
||||
/*++
|
||||
|
||||
Routine Description:
|
||||
|
||||
Get NumOfBits of bits out from mBitBuf. Fill mBitBuf with subsequent
|
||||
NumOfBits of bits from source. Returns NumOfBits of bits that are
|
||||
popped out.
|
||||
|
||||
Arguments:
|
||||
|
||||
Sd - The global scratch data.
|
||||
NumOfBits - The number of bits to pop and read.
|
||||
|
||||
Returns:
|
||||
|
||||
The bits that are popped out.
|
||||
|
||||
--*/
|
||||
{
|
||||
size_t OutBits;
|
||||
|
||||
OutBits = (Sd->mBitBuf >> (BITBUFSIZ - NumOfBits));
|
||||
|
||||
FillBuf (Sd, NumOfBits);
|
||||
|
||||
return OutBits;
|
||||
}
|
||||
|
||||
STATIC
|
||||
UINT16
|
||||
MakeTable (
|
||||
IN SCRATCH_DATA *Sd,
|
||||
IN UINT16 NumOfChar,
|
||||
const IN UINT8 *BitLen,
|
||||
IN UINT16 TableBits,
|
||||
OUT UINT16 *Table
|
||||
)
|
||||
/*++
|
||||
|
||||
Routine Description:
|
||||
|
||||
Creates Huffman Code mapping table according to code length array.
|
||||
|
||||
Arguments:
|
||||
|
||||
Sd - The global scratch data
|
||||
NumOfChar - Number of symbols in the symbol set
|
||||
BitLen - Code length array
|
||||
TableBits - The width of the mapping table
|
||||
Table - The table
|
||||
|
||||
Returns:
|
||||
|
||||
0 - OK.
|
||||
BAD_TABLE - The table is corrupted.
|
||||
|
||||
--*/
|
||||
{
|
||||
UINT16 Count[17];
|
||||
UINT16 Weight[17];
|
||||
UINT16 Start[18];
|
||||
UINT16 *Pointer;
|
||||
UINT16 TableSize;
|
||||
UINT16 Index3;
|
||||
UINT16 Index;
|
||||
UINT16 Len;
|
||||
UINT16 Char;
|
||||
UINT16 JuBits;
|
||||
UINT16 Avail;
|
||||
UINT16 NextCode;
|
||||
UINT16 Mask;
|
||||
|
||||
TableSize = (UINT16) (1U << TableBits);
|
||||
|
||||
for (Index = 1; Index <= 16; Index++) {
|
||||
Count[Index] = 0;
|
||||
}
|
||||
|
||||
for (Index = 0; Index < NumOfChar; Index++) {
|
||||
Count[BitLen[Index]]++;
|
||||
}
|
||||
|
||||
Start[1] = 0;
|
||||
|
||||
for (Index = 1; Index <= 16; Index++) {
|
||||
Start[Index + 1] = (UINT16) (Start[Index] + (Count[Index] << (16 - Index)));
|
||||
}
|
||||
|
||||
if (Start[17] != 0) {
|
||||
/*(1U << 16)*/
|
||||
return (UINT16) BAD_TABLE;
|
||||
}
|
||||
|
||||
JuBits = (UINT16) (16 - TableBits);
|
||||
|
||||
for (Index = 1; Index <= TableBits; Index++) {
|
||||
Start[Index] >>= JuBits;
|
||||
Weight[Index] = (UINT16) (1U << (TableBits - Index));
|
||||
}
|
||||
|
||||
while (Index <= 16) {
|
||||
Weight[Index] = (UINT16) (1U << (16 - Index));
|
||||
Index++;
|
||||
}
|
||||
|
||||
Index = (UINT16) (Start[TableBits + 1] >> JuBits);
|
||||
|
||||
if (Index != 0) {
|
||||
while (Index != TableSize) {
|
||||
Table[Index++] = 0;
|
||||
}
|
||||
}
|
||||
|
||||
Avail = NumOfChar;
|
||||
Mask = (UINT16) (1U << (15 - TableBits));
|
||||
|
||||
for (Char = 0; Char < NumOfChar; Char++) {
|
||||
|
||||
Len = BitLen[Char];
|
||||
if (Len == 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
NextCode = (UINT16) (Start[Len] + Weight[Len]);
|
||||
|
||||
if (Len <= TableBits) {
|
||||
|
||||
for (Index = Start[Len]; Index < NextCode; Index++) {
|
||||
if(Index >= TableSize)
|
||||
{
|
||||
Sd->mBadAlgorithm = 1;
|
||||
return (UINT16) BAD_TABLE;
|
||||
}
|
||||
Table[Index] = Char;
|
||||
}
|
||||
} else {
|
||||
|
||||
Index3 = Start[Len];
|
||||
Pointer = &Table[Index3 >> JuBits];
|
||||
Index = (UINT16) (Len - TableBits);
|
||||
|
||||
while (Index != 0) {
|
||||
if (*Pointer == 0) {
|
||||
Sd->mRight[Avail] = Sd->mLeft[Avail] = 0;
|
||||
*Pointer = Avail++;
|
||||
}
|
||||
|
||||
if (Index3 & Mask) {
|
||||
Pointer = &Sd->mRight[*Pointer];
|
||||
} else {
|
||||
Pointer = &Sd->mLeft[*Pointer];
|
||||
}
|
||||
|
||||
Index3 <<= 1;
|
||||
Index--;
|
||||
}
|
||||
|
||||
*Pointer = Char;
|
||||
|
||||
}
|
||||
|
||||
Start[Len] = NextCode;
|
||||
}
|
||||
//
|
||||
// Succeeds
|
||||
//
|
||||
return 0;
|
||||
}
|
||||
|
||||
STATIC
|
||||
size_t
|
||||
DecodeP (
|
||||
IN SCRATCH_DATA *Sd
|
||||
)
|
||||
/*++
|
||||
|
||||
Routine Description:
|
||||
|
||||
Decodes a position value.
|
||||
|
||||
Arguments:
|
||||
|
||||
Sd - the global scratch data
|
||||
|
||||
Returns:
|
||||
|
||||
The position value decoded.
|
||||
|
||||
--*/
|
||||
{
|
||||
UINT16 Val;
|
||||
size_t Mask;
|
||||
size_t Pos;
|
||||
|
||||
Val = Sd->mPTTable[Sd->mBitBuf >> (BITBUFSIZ - 8)];
|
||||
|
||||
if (Val >= MAXNP) {
|
||||
Mask = 1U << (BITBUFSIZ - 1 - 8);
|
||||
|
||||
do {
|
||||
|
||||
if (Sd->mBitBuf & Mask) {
|
||||
Val = Sd->mRight[Val];
|
||||
} else {
|
||||
Val = Sd->mLeft[Val];
|
||||
}
|
||||
|
||||
Mask >>= 1;
|
||||
} while (Val >= MAXNP);
|
||||
}
|
||||
//
|
||||
// Advance what we have read
|
||||
//
|
||||
FillBuf (Sd, Sd->mPTLen[Val]);
|
||||
|
||||
Pos = Val;
|
||||
if (Val > 1) {
|
||||
Pos = ((1LL << (Val - 1)) + GetBits (Sd, (UINT16) (Val - 1)));
|
||||
}
|
||||
|
||||
return Pos;
|
||||
}
|
||||
|
||||
STATIC
|
||||
UINT16
|
||||
ReadPTLen (
|
||||
IN SCRATCH_DATA *Sd,
|
||||
IN UINT16 nn,
|
||||
IN UINT16 nbit,
|
||||
IN UINT16 Special
|
||||
)
|
||||
/*++
|
||||
|
||||
Routine Description:
|
||||
|
||||
Reads code lengths for the Extra Set or the Position Set
|
||||
|
||||
Arguments:
|
||||
|
||||
Sd - The global scratch data
|
||||
nn - Number of symbols
|
||||
nbit - Number of bits needed to represent nn
|
||||
Special - The special symbol that needs to be taken care of
|
||||
|
||||
Returns:
|
||||
|
||||
0 - OK.
|
||||
BAD_TABLE - Table is corrupted.
|
||||
|
||||
--*/
|
||||
{
|
||||
UINT16 Number;
|
||||
UINT16 CharC;
|
||||
UINT16 Index;
|
||||
size_t Mask;
|
||||
|
||||
Number = (UINT16) GetBits (Sd, nbit);
|
||||
|
||||
if (Number == 0) {
|
||||
CharC = (UINT16) GetBits (Sd, nbit);
|
||||
|
||||
for (Index = 0; Index < 256; Index++) {
|
||||
Sd->mPTTable[Index] = CharC;
|
||||
}
|
||||
|
||||
for (Index = 0; Index < nn; Index++) {
|
||||
Sd->mPTLen[Index] = 0;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
Index = 0;
|
||||
|
||||
while (Index < Number) {
|
||||
|
||||
|
||||
|
||||
CharC = (UINT16) (Sd->mBitBuf >> (BITBUFSIZ - 3));
|
||||
|
||||
if (CharC == 7) {
|
||||
Mask = 1U << (BITBUFSIZ - 1 - 3);
|
||||
while (Mask & Sd->mBitBuf) {
|
||||
Mask >>= 1;
|
||||
CharC += 1;
|
||||
}
|
||||
}
|
||||
|
||||
FillBuf (Sd, (UINT16) ((CharC < 7) ? 3 : CharC - 3));
|
||||
|
||||
Sd->mPTLen[Index++] = (UINT8) CharC;
|
||||
|
||||
if (Index == Special) {
|
||||
CharC = (UINT16) GetBits (Sd, 2);
|
||||
CharC--;
|
||||
while ((INT16) (CharC) >= 0) {
|
||||
Sd->mPTLen[Index++] = 0;
|
||||
CharC--;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
while (Index < nn) {
|
||||
Sd->mPTLen[Index++] = 0;
|
||||
}
|
||||
|
||||
return MakeTable (Sd, nn, Sd->mPTLen, 8, Sd->mPTTable);
|
||||
}
|
||||
|
||||
STATIC
|
||||
VOID
|
||||
ReadCLen (
|
||||
SCRATCH_DATA *Sd
|
||||
)
|
||||
/*++
|
||||
|
||||
Routine Description:
|
||||
|
||||
Reads code lengths for Char&Len Set.
|
||||
|
||||
Arguments:
|
||||
|
||||
Sd - the global scratch data
|
||||
|
||||
Returns: (VOID)
|
||||
|
||||
--*/
|
||||
{
|
||||
UINT16 Number;
|
||||
UINT16 CharC;
|
||||
UINT16 Index;
|
||||
size_t Mask;
|
||||
|
||||
Number = (UINT16) GetBits (Sd, CBIT);
|
||||
|
||||
if (Number == 0) {
|
||||
CharC = (UINT16) GetBits (Sd, CBIT);
|
||||
|
||||
for (Index = 0; Index < NC; Index++) {
|
||||
Sd->mCLen[Index] = 0;
|
||||
}
|
||||
|
||||
for (Index = 0; Index < 4096; Index++) {
|
||||
Sd->mCTable[Index] = CharC;
|
||||
}
|
||||
|
||||
return ;
|
||||
}
|
||||
|
||||
Index = 0;
|
||||
while (Index < Number) {
|
||||
|
||||
CharC = Sd->mPTTable[Sd->mBitBuf >> (BITBUFSIZ - 8)];
|
||||
if (CharC >= NT) {
|
||||
Mask = 1U << (BITBUFSIZ - 1 - 8);
|
||||
|
||||
do {
|
||||
|
||||
if (Mask & Sd->mBitBuf) {
|
||||
CharC = Sd->mRight[CharC];
|
||||
} else {
|
||||
CharC = Sd->mLeft[CharC];
|
||||
}
|
||||
|
||||
Mask >>= 1;
|
||||
|
||||
} while (CharC >= NT);
|
||||
}
|
||||
//
|
||||
// Advance what we have read
|
||||
//
|
||||
FillBuf (Sd, Sd->mPTLen[CharC]);
|
||||
|
||||
if (CharC <= 2) {
|
||||
|
||||
if (CharC == 0) {
|
||||
CharC = 1;
|
||||
} else if (CharC == 1) {
|
||||
CharC = (UINT16) (GetBits (Sd, 4) + 3);
|
||||
} else if (CharC == 2) {
|
||||
CharC = (UINT16) (GetBits (Sd, CBIT) + 20);
|
||||
}
|
||||
|
||||
CharC--;
|
||||
while ((INT16) (CharC) >= 0) {
|
||||
Sd->mCLen[Index++] = 0;
|
||||
CharC--;
|
||||
}
|
||||
|
||||
} else {
|
||||
|
||||
Sd->mCLen[Index++] = (UINT8) (CharC - 2);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
while (Index < NC) {
|
||||
Sd->mCLen[Index++] = 0;
|
||||
}
|
||||
|
||||
MakeTable (Sd, NC, Sd->mCLen, 12, Sd->mCTable);
|
||||
|
||||
return ;
|
||||
}
|
||||
|
||||
STATIC
|
||||
UINT16
|
||||
DecodeC (
|
||||
SCRATCH_DATA *Sd
|
||||
)
|
||||
/*++
|
||||
|
||||
Routine Description:
|
||||
|
||||
Decode a character/length value.
|
||||
|
||||
Arguments:
|
||||
|
||||
Sd - The global scratch data.
|
||||
|
||||
Returns:
|
||||
|
||||
The value decoded.
|
||||
|
||||
--*/
|
||||
{
|
||||
UINT16 Index2;
|
||||
size_t Mask;
|
||||
|
||||
if (Sd->mBlockSize == 0) {
|
||||
//
|
||||
// Starting a new block
|
||||
//
|
||||
Sd->mBlockSize = (UINT16) GetBits (Sd, 16);
|
||||
Sd->mBadTableFlag = ReadPTLen (Sd, NT, TBIT, 3);
|
||||
if (Sd->mBadTableFlag != 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
ReadCLen (Sd);
|
||||
|
||||
Sd->mBadTableFlag = ReadPTLen (Sd, MAXNP, mPbit, (UINT16) (-1));
|
||||
if (Sd->mBadTableFlag != 0) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
Sd->mBlockSize--;
|
||||
Index2 = Sd->mCTable[Sd->mBitBuf >> (BITBUFSIZ - 12)];
|
||||
|
||||
if (Index2 >= NC) {
|
||||
Mask = 1U << (BITBUFSIZ - 1 - 12);
|
||||
|
||||
do {
|
||||
if (Sd->mBitBuf & Mask) {
|
||||
Index2 = Sd->mRight[Index2];
|
||||
} else {
|
||||
Index2 = Sd->mLeft[Index2];
|
||||
}
|
||||
|
||||
Mask >>= 1;
|
||||
} while (Index2 >= NC);
|
||||
}
|
||||
//
|
||||
// Advance what we have read
|
||||
//
|
||||
FillBuf (Sd, Sd->mCLen[Index2]);
|
||||
|
||||
return Index2;
|
||||
}
|
||||
|
||||
STATIC
|
||||
VOID
|
||||
Decode (
|
||||
SCRATCH_DATA *Sd
|
||||
)
|
||||
/*++
|
||||
|
||||
Routine Description:
|
||||
|
||||
Decode the source data and put the resulting data into the destination buffer.
|
||||
|
||||
Arguments:
|
||||
|
||||
Sd - The global scratch data
|
||||
|
||||
Returns: (VOID)
|
||||
|
||||
--*/
|
||||
{
|
||||
UINT16 BytesRemain;
|
||||
UINT64 DataIdx;
|
||||
UINT16 CharC;
|
||||
|
||||
BytesRemain = (UINT16) (-1);
|
||||
|
||||
DataIdx = 0;
|
||||
|
||||
while(1) {
|
||||
CharC = DecodeC (Sd);
|
||||
if (Sd->mBadTableFlag != 0) {
|
||||
return ;
|
||||
}
|
||||
|
||||
if (CharC < 256) {
|
||||
//
|
||||
// Process an Original character
|
||||
//
|
||||
Sd->mDstBase[Sd->mOutBuf++] = (UINT8) CharC;
|
||||
if (Sd->mOutBuf >= Sd->mOrigSize) {
|
||||
return ;
|
||||
}
|
||||
|
||||
} else {
|
||||
//
|
||||
// Process a Pointer
|
||||
//
|
||||
CharC = (UINT16) (CharC - (UINT8_MAX + 1 - THRESHOLD));
|
||||
|
||||
BytesRemain = CharC;
|
||||
|
||||
DataIdx = Sd->mOutBuf - DecodeP (Sd) - 1;
|
||||
// If this is not the correct decompression algorithm, this is an overflow possibility.
|
||||
if (DataIdx > Sd->mOrigSize) {
|
||||
Sd->mBadAlgorithm = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
BytesRemain--;
|
||||
while ((INT16) (BytesRemain) >= 0) {
|
||||
Sd->mDstBase[Sd->mOutBuf++] = Sd->mDstBase[DataIdx++];
|
||||
if (Sd->mOutBuf >= Sd->mOrigSize) {
|
||||
return;
|
||||
}
|
||||
BytesRemain--;
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
EFI_STATUS
|
||||
GetInfo (
|
||||
IN CONST VOID *Source,
|
||||
IN size_t SrcSize,
|
||||
OUT size_t *DstSize,
|
||||
OUT size_t *ScratchSize
|
||||
)
|
||||
/*++
|
||||
|
||||
Routine Description:
|
||||
|
||||
The implementation of EFI_DECOMPRESS_PROTOCOL.GetInfo().
|
||||
|
||||
Arguments:
|
||||
|
||||
Source - The source buffer containing the compressed data.
|
||||
SrcSize - The size of source buffer
|
||||
DstSize - The size of destination buffer.
|
||||
ScratchSize - The size of scratch buffer.
|
||||
|
||||
Returns:
|
||||
|
||||
EFI_SUCCESS - The size of destination buffer and the size of scratch buffer are successull retrieved.
|
||||
EFI_INVALID_PARAMETER - The source data is corrupted
|
||||
|
||||
--*/
|
||||
{
|
||||
UINT8 *Src;
|
||||
|
||||
*ScratchSize = sizeof (SCRATCH_DATA);
|
||||
|
||||
Src = Source;
|
||||
if (SrcSize < 8) {
|
||||
return EFI_INVALID_PARAMETER;
|
||||
}
|
||||
|
||||
*DstSize = (size_t)(Src[4]) + ((size_t)(Src[5]) << 8) + ((size_t)(Src[6]) << 16) + ((size_t)(Src[7]) << 24);
|
||||
return EFI_SUCCESS;
|
||||
}
|
||||
|
||||
EFI_STATUS
|
||||
Decompress (
|
||||
IN CONST VOID *Source,
|
||||
IN size_t SrcSize,
|
||||
IN OUT VOID *Destination,
|
||||
IN size_t DstSize,
|
||||
IN OUT VOID *Scratch,
|
||||
IN size_t ScratchSize
|
||||
)
|
||||
/*
|
||||
|
||||
Routine Description:
|
||||
|
||||
The implementation Efi and Tiano Decompress().
|
||||
|
||||
Arguments:
|
||||
|
||||
Source - The source buffer containing the compressed data.
|
||||
SrcSize - The size of source buffer
|
||||
Destination - The destination buffer to store the decompressed data
|
||||
DstSize - The size of destination buffer.
|
||||
Scratch - The buffer used internally by the decompress routine. This buffer is needed to store intermediate data.
|
||||
ScratchSize - The size of scratch buffer.
|
||||
|
||||
Returns:
|
||||
|
||||
EFI_SUCCESS - Decompression is successfull
|
||||
EFI_INVALID_PARAMETER - The source data is corrupted
|
||||
|
||||
--*/
|
||||
{
|
||||
size_t Index;
|
||||
size_t CompSize;
|
||||
size_t OrigSize;
|
||||
EFI_STATUS Status;
|
||||
SCRATCH_DATA *Sd;
|
||||
UINT8 *Src;
|
||||
UINT8 *Dst;
|
||||
|
||||
Status = EFI_SUCCESS;
|
||||
Src = Source;
|
||||
Dst = Destination;
|
||||
|
||||
if (ScratchSize < sizeof (SCRATCH_DATA)) {
|
||||
return EFI_INVALID_PARAMETER;
|
||||
}
|
||||
|
||||
Sd = (SCRATCH_DATA *) Scratch;
|
||||
|
||||
if (SrcSize < 8) {
|
||||
return EFI_INVALID_PARAMETER;
|
||||
}
|
||||
|
||||
CompSize = (size_t)(Src[0]) + ((size_t)(Src[1]) << 8) + ((size_t)(Src[2]) << 16) + ((size_t)(Src[3]) << 24);
|
||||
OrigSize = (size_t)(Src[4]) + ((size_t)(Src[5]) << 8) + ((size_t)(Src[6]) << 16) + ((size_t)(Src[7]) << 24);
|
||||
|
||||
if (SrcSize < CompSize + 8) {
|
||||
return EFI_INVALID_PARAMETER;
|
||||
}
|
||||
|
||||
if (DstSize != OrigSize) {
|
||||
return EFI_INVALID_PARAMETER;
|
||||
}
|
||||
|
||||
Src = Src + 8;
|
||||
|
||||
for (Index = 0; Index < ScratchSize; Index++) {
|
||||
((UINT8 *) Sd)[Index] = 0;
|
||||
}
|
||||
|
||||
Sd->mSrcBase = Src;
|
||||
Sd->mDstBase = Dst;
|
||||
Sd->mCompSize = CompSize;
|
||||
Sd->mOrigSize = DstSize;
|
||||
|
||||
//
|
||||
// Fill the first BITBUFSIZ bits
|
||||
//
|
||||
FillBuf (Sd, BITBUFSIZ);
|
||||
|
||||
//
|
||||
// Decompress it
|
||||
//
|
||||
Decode (Sd);
|
||||
|
||||
if (Sd->mBadTableFlag != 0 || Sd->mBadAlgorithm != 0) {
|
||||
//
|
||||
// Something wrong with the source
|
||||
//
|
||||
Status = EFI_INVALID_PARAMETER;
|
||||
}
|
||||
return Status;
|
||||
}
|
||||
|
||||
EFI_STATUS
|
||||
EfiGetInfo (
|
||||
IN CONST VOID *Source,
|
||||
IN size_t SrcSize,
|
||||
OUT size_t *DstSize,
|
||||
OUT size_t *ScratchSize
|
||||
)
|
||||
/*++
|
||||
|
||||
Routine Description:
|
||||
|
||||
The implementation Efi Decompress GetInfo().
|
||||
|
||||
Arguments:
|
||||
|
||||
Source - The source buffer containing the compressed data.
|
||||
SrcSize - The size of source buffer
|
||||
DstSize - The size of destination buffer.
|
||||
ScratchSize - The size of scratch buffer.
|
||||
|
||||
Returns:
|
||||
|
||||
EFI_SUCCESS - The size of destination buffer and the size of scratch buffer are successull retrieved.
|
||||
EFI_INVALID_PARAMETER - The source data is corrupted
|
||||
|
||||
--*/
|
||||
{
|
||||
return GetInfo (Source, SrcSize, DstSize, ScratchSize);
|
||||
}
|
||||
|
||||
EFI_STATUS
|
||||
TianoGetInfo ( //-V524
|
||||
IN CONST VOID *Source,
|
||||
IN size_t SrcSize,
|
||||
OUT size_t *DstSize,
|
||||
OUT size_t *ScratchSize
|
||||
)
|
||||
/*++
|
||||
|
||||
Routine Description:
|
||||
|
||||
The implementation Tiano Decompress GetInfo().
|
||||
|
||||
Arguments:
|
||||
|
||||
Source - The source buffer containing the compressed data.
|
||||
SrcSize - The size of source buffer
|
||||
DstSize - The size of destination buffer.
|
||||
ScratchSize - The size of scratch buffer.
|
||||
|
||||
Returns:
|
||||
|
||||
EFI_SUCCESS - The size of destination buffer and the size of scratch buffer are successull retrieved.
|
||||
EFI_INVALID_PARAMETER - The source data is corrupted
|
||||
|
||||
--*/
|
||||
{
|
||||
return GetInfo (Source, SrcSize, DstSize, ScratchSize);
|
||||
}
|
||||
|
||||
EFI_STATUS
|
||||
EfiDecompress (
|
||||
IN CONST VOID *Source,
|
||||
IN size_t SrcSize,
|
||||
IN OUT VOID *Destination,
|
||||
IN size_t DstSize,
|
||||
IN OUT VOID *Scratch,
|
||||
IN size_t ScratchSize
|
||||
)
|
||||
/*++
|
||||
|
||||
Routine Description:
|
||||
|
||||
The implementation of Efi Decompress().
|
||||
|
||||
Arguments:
|
||||
|
||||
Source - The source buffer containing the compressed data.
|
||||
SrcSize - The size of source buffer
|
||||
Destination - The destination buffer to store the decompressed data
|
||||
DstSize - The size of destination buffer.
|
||||
Scratch - The buffer used internally by the decompress routine. This buffer is needed to store intermediate data.
|
||||
ScratchSize - The size of scratch buffer.
|
||||
|
||||
Returns:
|
||||
|
||||
EFI_SUCCESS - Decompression is successfull
|
||||
EFI_INVALID_PARAMETER - The source data is corrupted
|
||||
|
||||
--*/
|
||||
{
|
||||
mPbit = EFIPBIT;
|
||||
return Decompress (Source, SrcSize, Destination, DstSize, Scratch, ScratchSize);
|
||||
}
|
||||
|
||||
EFI_STATUS
|
||||
TianoDecompress (
|
||||
IN CONST VOID *Source,
|
||||
IN size_t SrcSize,
|
||||
IN OUT VOID *Destination,
|
||||
IN size_t DstSize,
|
||||
IN OUT VOID *Scratch,
|
||||
IN size_t ScratchSize
|
||||
)
|
||||
/*++
|
||||
|
||||
Routine Description:
|
||||
|
||||
The implementation of Tiano Decompress().
|
||||
|
||||
Arguments:
|
||||
|
||||
Source - The source buffer containing the compressed data.
|
||||
SrcSize - The size of source buffer
|
||||
Destination - The destination buffer to store the decompressed data
|
||||
DstSize - The size of destination buffer.
|
||||
Scratch - The buffer used internally by the decompress routine. This buffer is needed to store intermediate data.
|
||||
ScratchSize - The size of scratch buffer.
|
||||
|
||||
Returns:
|
||||
|
||||
EFI_SUCCESS - Decompression is successfull
|
||||
EFI_INVALID_PARAMETER - The source data is corrupted
|
||||
|
||||
--*/
|
||||
{
|
||||
mPbit = MAXPBIT;
|
||||
return Decompress (Source, SrcSize, Destination, DstSize, Scratch, ScratchSize);
|
||||
}
|
||||
|
||||
|
||||
-143
@@ -1,143 +0,0 @@
|
||||
/** @file
|
||||
|
||||
Copyright (c) 2006 - 2008, Intel Corporation. All rights reserved.<BR>
|
||||
This program and the accompanying materials
|
||||
are licensed and made available under the terms and conditions of the BSD License
|
||||
which accompanies this distribution. The full text of the license may be found at
|
||||
http://opensource.org/licenses/bsd-license.php
|
||||
|
||||
THE PROGRAM IS DISTRIBUTED UNDER THE BSD LICENSE ON AN "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR REPRESENTATIONS OF ANY KIND, EITHER EXPRESS OR IMPLIED.
|
||||
|
||||
Module Name:
|
||||
|
||||
Decompress.h
|
||||
|
||||
Abstract:
|
||||
|
||||
Header file for compression routine
|
||||
|
||||
**/
|
||||
|
||||
#ifndef _EFI_DECOMPRESS_H
|
||||
#define _EFI_DECOMPRESS_H
|
||||
|
||||
#include "BaseTypes.h"
|
||||
|
||||
EFI_STATUS
|
||||
EfiGetInfo (
|
||||
IN CONST VOID *Source,
|
||||
IN size_t SrcSize,
|
||||
OUT size_t *DstSize,
|
||||
OUT size_t *ScratchSize
|
||||
);
|
||||
/**
|
||||
|
||||
Routine Description:
|
||||
|
||||
The implementation Efi Decompress GetInfo().
|
||||
|
||||
Arguments:
|
||||
|
||||
Source - The source buffer containing the compressed data.
|
||||
SrcSize - The size of source buffer
|
||||
DstSize - The size of destination buffer.
|
||||
ScratchSize - The size of scratch buffer.
|
||||
|
||||
Returns:
|
||||
|
||||
EFI_SUCCESS - The size of destination buffer and the size of scratch buffer are successull retrieved.
|
||||
EFI_INVALID_PARAMETER - The source data is corrupted
|
||||
|
||||
**/
|
||||
|
||||
EFI_STATUS
|
||||
EfiDecompress (
|
||||
IN CONST VOID *Source,
|
||||
IN size_t SrcSize,
|
||||
IN OUT VOID *Destination,
|
||||
IN size_t DstSize,
|
||||
IN OUT VOID *Scratch,
|
||||
IN size_t ScratchSize
|
||||
);
|
||||
/**
|
||||
|
||||
Routine Description:
|
||||
|
||||
The implementation of Efi Decompress().
|
||||
|
||||
Arguments:
|
||||
|
||||
Source - The source buffer containing the compressed data.
|
||||
SrcSize - The size of source buffer
|
||||
Destination - The destination buffer to store the decompressed data
|
||||
DstSize - The size of destination buffer.
|
||||
Scratch - The buffer used internally by the decompress routine. This buffer is needed to store intermediate data.
|
||||
ScratchSize - The size of scratch buffer.
|
||||
|
||||
Returns:
|
||||
|
||||
EFI_SUCCESS - Decompression is successfull
|
||||
EFI_INVALID_PARAMETER - The source data is corrupted
|
||||
|
||||
**/
|
||||
|
||||
EFI_STATUS
|
||||
TianoGetInfo (
|
||||
IN CONST VOID *Source,
|
||||
IN size_t SrcSize,
|
||||
OUT size_t *DstSize,
|
||||
OUT size_t *ScratchSize
|
||||
);
|
||||
/**
|
||||
|
||||
Routine Description:
|
||||
|
||||
The implementation Tiano Decompress GetInfo().
|
||||
|
||||
Arguments:
|
||||
|
||||
Source - The source buffer containing the compressed data.
|
||||
SrcSize - The size of source buffer
|
||||
DstSize - The size of destination buffer.
|
||||
ScratchSize - The size of scratch buffer.
|
||||
|
||||
Returns:
|
||||
|
||||
EFI_SUCCESS - The size of destination buffer and the size of scratch buffer are successull retrieved.
|
||||
EFI_INVALID_PARAMETER - The source data is corrupted
|
||||
|
||||
**/
|
||||
|
||||
EFI_STATUS
|
||||
TianoDecompress (
|
||||
IN CONST VOID *Source,
|
||||
IN size_t SrcSize,
|
||||
IN OUT VOID *Destination,
|
||||
IN size_t DstSize,
|
||||
IN OUT VOID *Scratch,
|
||||
IN size_t ScratchSize
|
||||
);
|
||||
/**
|
||||
|
||||
Routine Description:
|
||||
|
||||
The implementation of Tiano Decompress().
|
||||
|
||||
Arguments:
|
||||
|
||||
Source - The source buffer containing the compressed data.
|
||||
SrcSize - The size of source buffer
|
||||
Destination - The destination buffer to store the decompressed data
|
||||
DstSize - The size of destination buffer.
|
||||
Scratch - The buffer used internally by the decompress routine. This buffer is needed to store intermediate data.
|
||||
ScratchSize - The size of scratch buffer.
|
||||
|
||||
Returns:
|
||||
|
||||
EFI_SUCCESS - Decompression is successfull
|
||||
EFI_INVALID_PARAMETER - The source data is corrupted
|
||||
|
||||
**/
|
||||
|
||||
#endif
|
||||
-1580
File diff suppressed because it is too large.
Load diff
-1733
File diff suppressed because it is too large.
Load diff
-466
@@ -1,466 +0,0 @@
|
||||
// Copyright IBM Corp. 2015, 2025
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package hostvolumemanager
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/hashicorp/go-hclog"
|
||||
"github.com/hashicorp/go-multierror"
|
||||
"github.com/hashicorp/go-version"
|
||||
cstructs "github.com/hashicorp/nomad/client/structs"
|
||||
"github.com/hashicorp/nomad/helper"
|
||||
)
|
||||
|
||||
const (
|
||||
// environment variables for external plugins
|
||||
|
||||
EnvOperation = "DHV_OPERATION"
|
||||
EnvVolumesDir = "DHV_VOLUMES_DIR"
|
||||
EnvPluginDir = "DHV_PLUGIN_DIR"
|
||||
EnvCreatedPath = "DHV_CREATED_PATH"
|
||||
EnvNamespace = "DHV_NAMESPACE"
|
||||
EnvVolumeName = "DHV_VOLUME_NAME"
|
||||
EnvVolumeID = "DHV_VOLUME_ID"
|
||||
EnvNodeID = "DHV_NODE_ID"
|
||||
EnvNodePool = "DHV_NODE_POOL"
|
||||
EnvCapacityMin = "DHV_CAPACITY_MIN_BYTES"
|
||||
EnvCapacityMax = "DHV_CAPACITY_MAX_BYTES"
|
||||
EnvParameters = "DHV_PARAMETERS"
|
||||
)
|
||||
|
||||
// HostVolumePlugin manages the lifecycle of volumes.
|
||||
type HostVolumePlugin interface {
|
||||
Fingerprint(ctx context.Context) (*PluginFingerprint, error)
|
||||
Create(ctx context.Context, req *cstructs.ClientHostVolumeCreateRequest) (*HostVolumePluginCreateResponse, error)
|
||||
Delete(ctx context.Context, req *cstructs.ClientHostVolumeDeleteRequest) error
|
||||
}
|
||||
|
||||
// PluginFingerprint gets set on the node for volume scheduling.
|
||||
// Plugins are expected to respond to 'fingerprint' calls with json that
|
||||
// unmarshals to this struct.
|
||||
type PluginFingerprint struct {
|
||||
Version *version.Version `json:"version"`
|
||||
}
|
||||
|
||||
// HostVolumePluginCreateResponse returns values to the server that may be shown
|
||||
// to the user or stored in server state. Plugins are expected to respond to
|
||||
// 'create' calls with json that unmarshals to this struct.
|
||||
type HostVolumePluginCreateResponse struct {
|
||||
Path string `json:"path"`
|
||||
SizeBytes int64 `json:"bytes"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
// HostVolumePluginDeleteResponse returns values to the server that may be shown
|
||||
// to the user or stored in server state. Plugins are expected to respond to
|
||||
// 'delete' calls with json that unmarshals to this struct.
|
||||
type HostVolumePluginDeleteResponse struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
const HostVolumePluginMkdirID = "mkdir"
|
||||
const HostVolumePluginMkdirVersion = "0.0.1"
|
||||
|
||||
// HostVolumePluginMkdirParams represents the parameters{} that the "mkdir"
|
||||
// plugin will accept.
|
||||
type HostVolumePluginMkdirParams struct {
|
||||
Uid int
|
||||
Gid int
|
||||
Mode os.FileMode
|
||||
}
|
||||
|
||||
var _ HostVolumePlugin = &HostVolumePluginMkdir{}
|
||||
|
||||
// HostVolumePluginMkdir is a plugin that creates a directory within the
|
||||
// specified VolumesDir. It is built-in to Nomad, so is always available.
|
||||
type HostVolumePluginMkdir struct {
|
||||
ID string
|
||||
VolumesDir string
|
||||
|
||||
log hclog.Logger
|
||||
}
|
||||
|
||||
func (p *HostVolumePluginMkdir) Fingerprint(_ context.Context) (*PluginFingerprint, error) {
|
||||
v, err := version.NewVersion(HostVolumePluginMkdirVersion)
|
||||
return &PluginFingerprint{
|
||||
Version: v,
|
||||
}, err
|
||||
}
|
||||
|
||||
func (p *HostVolumePluginMkdir) Create(_ context.Context,
|
||||
req *cstructs.ClientHostVolumeCreateRequest) (*HostVolumePluginCreateResponse, error) {
|
||||
|
||||
path := filepath.Join(p.VolumesDir, req.ID)
|
||||
log := p.log.With(
|
||||
"operation", "create",
|
||||
"volume_id", req.ID,
|
||||
"path", path)
|
||||
log.Debug("running plugin")
|
||||
|
||||
resp := &HostVolumePluginCreateResponse{
|
||||
Path: path,
|
||||
// "mkdir" volumes, being simple directories, have unrestricted size
|
||||
SizeBytes: 0,
|
||||
}
|
||||
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
// already exists
|
||||
return resp, nil
|
||||
} else if !os.IsNotExist(err) {
|
||||
// doesn't exist, but some other path error
|
||||
log.Error("error with path", "error", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
params, err := decodeMkdirParams(req.Parameters)
|
||||
if err != nil {
|
||||
log.Error("error with parameters", "error", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = os.MkdirAll(path, params.Mode)
|
||||
if err != nil {
|
||||
log.Error("error creating directory", "error", err)
|
||||
return nil, fmt.Errorf("error creating directory: %w", err)
|
||||
}
|
||||
|
||||
// os.MkdirAll perms are applied after umask, so the new directory may not
|
||||
// have the exact permissions requested.
|
||||
err = os.Chmod(path, params.Mode)
|
||||
if err != nil {
|
||||
log.Error("error setting directory permission mode", "error", err)
|
||||
return nil, fmt.Errorf("error setting directory permission mode: %w", err)
|
||||
}
|
||||
|
||||
if runtime.GOOS != "windows" {
|
||||
// Chown note: A uid or gid of -1 means to not change that value.
|
||||
if err = os.Chown(path, params.Uid, params.Gid); err != nil {
|
||||
log.Error("error changing owner/group", "error", err, "uid", params.Uid, "gid", params.Gid)
|
||||
|
||||
// Failing to change ownership is fatal for this plugin. Since we have
|
||||
// already created the directory, we should attempt to clean it.
|
||||
// Otherwise, the operator must do this manually.
|
||||
if err := os.RemoveAll(path); err != nil {
|
||||
log.Error("failed to remove directory after create failure",
|
||||
"error", err)
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("error changing owner/group: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
log.Debug("plugin ran successfully")
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func decodeMkdirParams(in map[string]string) (HostVolumePluginMkdirParams, error) {
|
||||
// default values if their associated keys are not in the input map
|
||||
out := HostVolumePluginMkdirParams{
|
||||
Mode: 0o700, // "0700"
|
||||
Uid: -1, // this default translates to "do not set" in os.Chown
|
||||
Gid: -1, // ditto
|
||||
}
|
||||
var err error
|
||||
|
||||
for param, val := range in {
|
||||
switch param {
|
||||
case "mode":
|
||||
// mode needs special treatment - it's octal. note that this does
|
||||
// not check whether it's a *reasonable* mode for a directory.
|
||||
// that will be discovered during MkdirAll and subsequent usage
|
||||
// by workloads (which we cannot predict).
|
||||
var number uint64
|
||||
number, err = strconv.ParseUint(val, 8, 32)
|
||||
out.Mode = os.FileMode(number)
|
||||
case "uid":
|
||||
out.Uid, err = strconv.Atoi(val)
|
||||
case "gid":
|
||||
out.Gid, err = strconv.Atoi(val)
|
||||
default:
|
||||
err = fmt.Errorf("unknown mkdir parameter: %q", param)
|
||||
}
|
||||
if err != nil {
|
||||
return out, fmt.Errorf("invalid value for %q: %w", param, err)
|
||||
}
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (p *HostVolumePluginMkdir) Delete(_ context.Context, req *cstructs.ClientHostVolumeDeleteRequest) error {
|
||||
path := filepath.Join(p.VolumesDir, req.ID)
|
||||
log := p.log.With(
|
||||
"operation", "delete",
|
||||
"volume_id", req.ID,
|
||||
"path", path)
|
||||
log.Debug("running plugin")
|
||||
|
||||
err := os.RemoveAll(path)
|
||||
if err != nil {
|
||||
log.Error("error with plugin", "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
log.Debug("plugin ran successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
var _ HostVolumePlugin = &HostVolumePluginExternal{}
|
||||
|
||||
// NewHostVolumePluginExternal returns an external host volume plugin
|
||||
// if the specified executable exists on disk.
|
||||
func NewHostVolumePluginExternal(log hclog.Logger,
|
||||
pluginDir, filename, volumesDir, nodePool string) (*HostVolumePluginExternal, error) {
|
||||
// this should only be called with already-detected executables,
|
||||
// but we'll double-check it anyway, so we can provide a tidy error message
|
||||
// if it has changed between fingerprinting and execution.
|
||||
executable := filepath.Join(pluginDir, filename)
|
||||
f, err := os.Stat(executable)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("%w: %q", ErrPluginNotExists, filename)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if !helper.IsExecutable(f) {
|
||||
return nil, fmt.Errorf("%w: %q", ErrPluginNotExecutable, filename)
|
||||
}
|
||||
return &HostVolumePluginExternal{
|
||||
ID: filename,
|
||||
Executable: executable,
|
||||
VolumesDir: volumesDir,
|
||||
PluginDir: pluginDir,
|
||||
NodePool: nodePool,
|
||||
log: log,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// HostVolumePluginExternal calls an executable on disk. All operations
|
||||
// *must* be idempotent, and safe to be called concurrently per volume.
|
||||
// For each call, the executable's stdout and stderr may be logged, so plugin
|
||||
// authors should not include any sensitive information in their plugin outputs.
|
||||
type HostVolumePluginExternal struct {
|
||||
ID string
|
||||
Executable string
|
||||
VolumesDir string
|
||||
PluginDir string
|
||||
NodePool string
|
||||
|
||||
log hclog.Logger
|
||||
}
|
||||
|
||||
// Fingerprint calls the executable with the following parameters:
|
||||
// arguments: $1=fingerprint
|
||||
// environment:
|
||||
// - DHV_OPERATION=fingerprint
|
||||
//
|
||||
// Response should be valid JSON on stdout, with a "version" key, e.g.:
|
||||
// {"version": "0.0.1"}
|
||||
// The version value should be a valid version number as allowed by
|
||||
// version.NewVersion()
|
||||
//
|
||||
// Must complete within 5 seconds
|
||||
func (p *HostVolumePluginExternal) Fingerprint(ctx context.Context) (*PluginFingerprint, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, p.Executable, "fingerprint")
|
||||
cmd.Env = []string{EnvOperation + "=fingerprint"}
|
||||
stdout, stderr, err := runCommand(cmd)
|
||||
log := p.log.With(
|
||||
"operation", "fingerprint",
|
||||
"stdout", string(stdout),
|
||||
"stderr", string(stderr),
|
||||
)
|
||||
if err != nil {
|
||||
log.Error("error running plugin", "error", err)
|
||||
return nil, fmt.Errorf("error fingerprinting plugin %q: %w", p.ID, err)
|
||||
}
|
||||
fprint := &PluginFingerprint{}
|
||||
if err := json.Unmarshal(stdout, fprint); err != nil {
|
||||
err = fmt.Errorf("error parsing fingerprint output as json: %w", err)
|
||||
log.Error("error parsing plugin output", "error", err)
|
||||
return nil, err
|
||||
}
|
||||
return fprint, nil
|
||||
}
|
||||
|
||||
// Create calls the executable with the following parameters:
|
||||
// arguments: $1=create
|
||||
// environment:
|
||||
// - DHV_OPERATION=create
|
||||
// - DHV_VOLUMES_DIR={directory to put the volume in}
|
||||
// - DHV_PLUGIN_DIR={path to directory containing plugins}
|
||||
// - DHV_NAMESPACE={volume namespace}
|
||||
// - DHV_VOLUME_NAME={name from the volume specification}
|
||||
// - DHV_VOLUME_ID={volume ID generated by Nomad}
|
||||
// - DHV_NODE_ID={Nomad node ID}
|
||||
// - DHV_NODE_POOL={Nomad node pool}
|
||||
// - DHV_CAPACITY_MIN_BYTES={capacity_min from the volume spec, expressed in bytes}
|
||||
// - DHV_CAPACITY_MAX_BYTES={capacity_max from the volume spec, expressed in bytes}
|
||||
// - DHV_PARAMETERS={stringified json of parameters from the volume spec}
|
||||
//
|
||||
// Response should be valid JSON on stdout with "path" and "bytes", e.g.:
|
||||
// {"path": "/path/that/was/created", "bytes": 50000000}
|
||||
// "path" must be provided to confirm the requested path is what was
|
||||
// created by the plugin. "bytes" is the actual size of the volume created
|
||||
// by the plugin; if excluded, it will default to 0.
|
||||
//
|
||||
// Must complete within 60 seconds (timeout on RPC)
|
||||
func (p *HostVolumePluginExternal) Create(ctx context.Context,
|
||||
req *cstructs.ClientHostVolumeCreateRequest) (*HostVolumePluginCreateResponse, error) {
|
||||
|
||||
params, err := json.Marshal(req.Parameters)
|
||||
if err != nil {
|
||||
// should never happen; req.Parameters is a simple map[string]string
|
||||
return nil, fmt.Errorf("error marshaling volume pramaters: %w", err)
|
||||
}
|
||||
envVars := []string{
|
||||
fmt.Sprintf("%s=%s", EnvOperation, "create"),
|
||||
fmt.Sprintf("%s=%s", EnvVolumesDir, p.VolumesDir),
|
||||
fmt.Sprintf("%s=%s", EnvPluginDir, p.PluginDir),
|
||||
fmt.Sprintf("%s=%s", EnvNodePool, p.NodePool),
|
||||
// values from volume spec
|
||||
fmt.Sprintf("%s=%s", EnvNamespace, req.Namespace),
|
||||
fmt.Sprintf("%s=%s", EnvVolumeName, req.Name),
|
||||
fmt.Sprintf("%s=%s", EnvVolumeID, req.ID),
|
||||
fmt.Sprintf("%s=%d", EnvCapacityMin, req.RequestedCapacityMinBytes),
|
||||
fmt.Sprintf("%s=%d", EnvCapacityMax, req.RequestedCapacityMaxBytes),
|
||||
fmt.Sprintf("%s=%s", EnvNodeID, req.NodeID),
|
||||
fmt.Sprintf("%s=%s", EnvParameters, params),
|
||||
}
|
||||
|
||||
var pluginResp HostVolumePluginCreateResponse
|
||||
log := p.log.With("volume_name", req.Name, "volume_id", req.ID)
|
||||
stdout, _, err := p.runPlugin(ctx, log, "create", envVars)
|
||||
if err != nil {
|
||||
jsonErr := json.Unmarshal(stdout, &pluginResp)
|
||||
if jsonErr != nil {
|
||||
// if we got an error, we can't actually count on getting JSON, so
|
||||
// optimistically look for it and return the original error
|
||||
// otherwise
|
||||
return nil, fmt.Errorf(
|
||||
"error creating volume %q with plugin %q: %w", req.ID, p.ID, err)
|
||||
}
|
||||
return nil, fmt.Errorf("error creating volume %q with plugin %q: %w: %s",
|
||||
req.ID, p.ID, err, pluginResp.Error)
|
||||
}
|
||||
err = json.Unmarshal(stdout, &pluginResp)
|
||||
if err != nil {
|
||||
// note: if a plugin does not return valid json, a volume may be
|
||||
// created without any respective state in Nomad, since we return
|
||||
// an error here after the plugin has done who-knows-what.
|
||||
return nil, err
|
||||
}
|
||||
return &pluginResp, nil
|
||||
}
|
||||
|
||||
// Delete calls the executable with the following parameters:
|
||||
// arguments: $1=delete
|
||||
// environment:
|
||||
// - DHV_OPERATION=delete
|
||||
// - DHV_CREATED_PATH={path that `create` returned}
|
||||
// - DHV_VOLUMES_DIR={directory that volumes should be put in}
|
||||
// - DHV_PLUGIN_DIR={path to directory containing plugins}
|
||||
// - DHV_NAMESPACE={volume namespace}
|
||||
// - DHV_VOLUME_NAME={name from the volume specification}
|
||||
// - DHV_VOLUME_ID={volume ID generated by Nomad}
|
||||
// - DHV_NODE_ID={Nomad node ID}
|
||||
// - DHV_NODE_POOL={Nomad node pool}
|
||||
// - DHV_PARAMETERS={stringified json of parameters from the volume spec}
|
||||
//
|
||||
// Response on stdout is discarded.
|
||||
//
|
||||
// Must complete within 60 seconds (timeout on RPC)
|
||||
func (p *HostVolumePluginExternal) Delete(ctx context.Context,
|
||||
req *cstructs.ClientHostVolumeDeleteRequest) error {
|
||||
|
||||
params, err := json.Marshal(req.Parameters)
|
||||
if err != nil {
|
||||
// should never happen; req.Parameters is a simple map[string]string
|
||||
return fmt.Errorf("error marshaling volume pramaters: %w", err)
|
||||
}
|
||||
envVars := []string{
|
||||
fmt.Sprintf("%s=%s", EnvOperation, "delete"),
|
||||
fmt.Sprintf("%s=%s", EnvVolumesDir, p.VolumesDir),
|
||||
fmt.Sprintf("%s=%s", EnvPluginDir, p.PluginDir),
|
||||
fmt.Sprintf("%s=%s", EnvNodePool, p.NodePool),
|
||||
// from create response
|
||||
fmt.Sprintf("%s=%s", EnvCreatedPath, req.HostPath),
|
||||
// values from volume spec
|
||||
fmt.Sprintf("%s=%s", EnvNamespace, req.Namespace),
|
||||
fmt.Sprintf("%s=%s", EnvVolumeName, req.Name),
|
||||
fmt.Sprintf("%s=%s", EnvVolumeID, req.ID),
|
||||
fmt.Sprintf("%s=%s", EnvNodeID, req.NodeID),
|
||||
fmt.Sprintf("%s=%s", EnvParameters, params),
|
||||
}
|
||||
|
||||
log := p.log.With("volume_name", req.Name, "volume_id", req.ID)
|
||||
stdout, _, err := p.runPlugin(ctx, log, "delete", envVars)
|
||||
if err != nil {
|
||||
var pluginResp HostVolumePluginDeleteResponse
|
||||
jsonErr := json.Unmarshal(stdout, &pluginResp)
|
||||
if jsonErr != nil {
|
||||
// if we got an error, we can't actually count on getting JSON, so
|
||||
// optimistically look for it and return the original error
|
||||
// otherwise
|
||||
return fmt.Errorf("error reading plugin response when deleting volume %q with plugin %q: original error: %w", req.ID, p.ID, err)
|
||||
}
|
||||
return fmt.Errorf("error deleting volume %q with plugin %q: %w: %s",
|
||||
req.ID, p.ID, err, pluginResp.Error)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// runPlugin executes the... executable
|
||||
func (p *HostVolumePluginExternal) runPlugin(ctx context.Context, log hclog.Logger,
|
||||
op string, env []string) (stdout, stderr []byte, err error) {
|
||||
|
||||
log = log.With("operation", op)
|
||||
log.Debug("running plugin")
|
||||
|
||||
// set up plugin execution
|
||||
cmd := exec.CommandContext(ctx, p.Executable, op)
|
||||
cmd.Env = env
|
||||
|
||||
stdout, stderr, err = runCommand(cmd)
|
||||
|
||||
log = log.With(
|
||||
"stdout", string(stdout),
|
||||
"stderr", string(stderr),
|
||||
)
|
||||
if err != nil {
|
||||
log.Error("error with plugin", "error", err)
|
||||
return stdout, stderr, err
|
||||
}
|
||||
log.Debug("plugin ran successfully")
|
||||
return stdout, stderr, nil
|
||||
}
|
||||
|
||||
// runCommand executes the provided Cmd and captures stdout and stderr.
|
||||
func runCommand(cmd *exec.Cmd) (stdout, stderr []byte, err error) {
|
||||
var errBuf bytes.Buffer
|
||||
cmd.Stderr = io.Writer(&errBuf)
|
||||
mErr := &multierror.Error{}
|
||||
stdout, err = cmd.Output()
|
||||
if err != nil {
|
||||
mErr = multierror.Append(mErr, err)
|
||||
}
|
||||
stderr, err = io.ReadAll(&errBuf)
|
||||
if err != nil {
|
||||
mErr = multierror.Append(mErr, err)
|
||||
}
|
||||
return stdout, stderr, helper.FlattenMultierror(mErr.ErrorOrNil())
|
||||
}
|
||||
-346
@@ -1,346 +0,0 @@
|
||||
// Copyright IBM Corp. 2015, 2025
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package hostvolumemanager
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/hashicorp/go-version"
|
||||
"github.com/hashicorp/nomad/ci"
|
||||
cstructs "github.com/hashicorp/nomad/client/structs"
|
||||
"github.com/hashicorp/nomad/helper/testlog"
|
||||
"github.com/shoenig/test/must"
|
||||
)
|
||||
|
||||
func TestHostVolumePluginMkdir(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
tmp := t.TempDir()
|
||||
|
||||
plug := &HostVolumePluginMkdir{
|
||||
ID: "test-mkdir-plugin",
|
||||
VolumesDir: tmp,
|
||||
log: testlog.HCLogger(t),
|
||||
}
|
||||
|
||||
// contexts don't matter here, since they're thrown away by this plugin,
|
||||
// but sending timeout contexts anyway, in case the plugin changes later.
|
||||
_, err := plug.Fingerprint(timeout(t))
|
||||
must.NoError(t, err)
|
||||
|
||||
t.Run("happy", func(t *testing.T) {
|
||||
volID := "happy"
|
||||
target := filepath.Join(tmp, volID)
|
||||
// run multiple times, should be idempotent
|
||||
for range 2 {
|
||||
resp, err := plug.Create(timeout(t),
|
||||
&cstructs.ClientHostVolumeCreateRequest{
|
||||
ID: volID, // minimum required by this plugin
|
||||
})
|
||||
must.NoError(t, err)
|
||||
must.Eq(t, &HostVolumePluginCreateResponse{
|
||||
Path: target,
|
||||
SizeBytes: 0,
|
||||
}, resp)
|
||||
must.DirExists(t, target)
|
||||
must.DirMode(t, target, 0o700+os.ModeDir)
|
||||
}
|
||||
|
||||
// delete should be idempotent, too
|
||||
for range 2 {
|
||||
err = plug.Delete(timeout(t),
|
||||
&cstructs.ClientHostVolumeDeleteRequest{
|
||||
ID: volID,
|
||||
})
|
||||
must.NoError(t, err)
|
||||
must.DirNotExists(t, target)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("sad", func(t *testing.T) {
|
||||
volID := "sad"
|
||||
// can't mkdir inside a file
|
||||
plug.VolumesDir = "host_volume_plugin_test.go"
|
||||
t.Cleanup(func() {
|
||||
plug.VolumesDir = tmp
|
||||
})
|
||||
|
||||
resp, err := plug.Create(timeout(t),
|
||||
&cstructs.ClientHostVolumeCreateRequest{
|
||||
ID: volID, // minimum required by this plugin
|
||||
})
|
||||
must.ErrorContains(t, err, "host_volume_plugin_test.go/sad: not a directory")
|
||||
must.Nil(t, resp)
|
||||
|
||||
err = plug.Delete(timeout(t),
|
||||
&cstructs.ClientHostVolumeDeleteRequest{
|
||||
ID: volID,
|
||||
})
|
||||
must.ErrorContains(t, err, "host_volume_plugin_test.go/sad: not a directory")
|
||||
})
|
||||
|
||||
t.Run("happy params", func(t *testing.T) {
|
||||
volID := "happy_params"
|
||||
target := filepath.Join(tmp, volID)
|
||||
currentUser, err := user.Current()
|
||||
must.NoError(t, err)
|
||||
// run multiple times, should be idempotent
|
||||
for range 2 {
|
||||
_, err := plug.Create(timeout(t),
|
||||
&cstructs.ClientHostVolumeCreateRequest{
|
||||
ID: volID,
|
||||
Parameters: map[string]string{
|
||||
"uid": currentUser.Uid,
|
||||
"gid": currentUser.Gid,
|
||||
"mode": "0777",
|
||||
},
|
||||
})
|
||||
must.NoError(t, err)
|
||||
must.DirExists(t, target)
|
||||
must.DirMode(t, target, 0o777+os.ModeDir)
|
||||
}
|
||||
|
||||
err = plug.Delete(timeout(t),
|
||||
&cstructs.ClientHostVolumeDeleteRequest{
|
||||
ID: volID,
|
||||
})
|
||||
must.NoError(t, err)
|
||||
must.DirNotExists(t, target)
|
||||
})
|
||||
|
||||
t.Run("sad params", func(t *testing.T) {
|
||||
volID := "sad_params"
|
||||
// test one representative error; decodeMkdirParams has its own tests
|
||||
resp, err := plug.Create(timeout(t),
|
||||
&cstructs.ClientHostVolumeCreateRequest{
|
||||
ID: volID,
|
||||
Parameters: map[string]string{
|
||||
"mode": "invalid",
|
||||
},
|
||||
})
|
||||
must.ErrorContains(t, err, "invalid value")
|
||||
must.Nil(t, resp)
|
||||
})
|
||||
}
|
||||
|
||||
func TestDecodeMkdirParams(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
params map[string]string
|
||||
expect HostVolumePluginMkdirParams
|
||||
err string
|
||||
}{
|
||||
{
|
||||
name: "none ok",
|
||||
params: nil,
|
||||
expect: HostVolumePluginMkdirParams{
|
||||
Uid: -1,
|
||||
Gid: -1,
|
||||
Mode: os.FileMode(0700),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "all ok",
|
||||
params: map[string]string{
|
||||
"uid": "1",
|
||||
"gid": "2",
|
||||
"mode": "0444",
|
||||
},
|
||||
expect: HostVolumePluginMkdirParams{
|
||||
Uid: 1,
|
||||
Gid: 2,
|
||||
Mode: os.FileMode(0444),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid mode: bad number",
|
||||
params: map[string]string{
|
||||
// this is what happens if you put mode=0700 instead of
|
||||
// mode="0700" in the HCL spec.
|
||||
"mode": "493",
|
||||
},
|
||||
err: `invalid value for "mode"`,
|
||||
},
|
||||
{
|
||||
name: "invalid mode: string",
|
||||
params: map[string]string{
|
||||
"mode": "consider the lobster",
|
||||
},
|
||||
err: `invalid value for "mode"`,
|
||||
},
|
||||
{
|
||||
name: "invalid uid",
|
||||
params: map[string]string{
|
||||
"uid": "a supposedly fun thing i'll never do again",
|
||||
},
|
||||
err: `invalid value for "uid"`,
|
||||
},
|
||||
{
|
||||
name: "invalid gid",
|
||||
params: map[string]string{
|
||||
"gid": "surely you jest",
|
||||
},
|
||||
err: `invalid value for "gid"`,
|
||||
},
|
||||
{
|
||||
name: "unknown param",
|
||||
params: map[string]string{
|
||||
"what": "the hell is water?",
|
||||
},
|
||||
err: `unknown mkdir parameter: "what"`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := decodeMkdirParams(tc.params)
|
||||
if tc.err != "" {
|
||||
must.ErrorContains(t, err, tc.err)
|
||||
} else {
|
||||
must.NoError(t, err)
|
||||
must.Eq(t, tc.expect, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewHostVolumePluginExternal(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
log := testlog.HCLogger(t)
|
||||
var err error
|
||||
|
||||
_, err = NewHostVolumePluginExternal(log, ".", "non-existent", "target", "")
|
||||
must.ErrorIs(t, err, ErrPluginNotExists)
|
||||
|
||||
_, err = NewHostVolumePluginExternal(log, ".", "host_volume_plugin_test.go", "target", "")
|
||||
must.ErrorIs(t, err, ErrPluginNotExecutable)
|
||||
|
||||
t.Run("unix", func(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("skipped because windows") // db TODO(1.10.0)
|
||||
}
|
||||
p, err := NewHostVolumePluginExternal(log,
|
||||
"./test_fixtures", "test_plugin.sh", "test-target", "test-pool")
|
||||
must.NoError(t, err)
|
||||
must.Eq(t, &HostVolumePluginExternal{
|
||||
ID: "test_plugin.sh",
|
||||
Executable: "test_fixtures/test_plugin.sh",
|
||||
VolumesDir: "test-target",
|
||||
PluginDir: "./test_fixtures",
|
||||
NodePool: "test-pool",
|
||||
log: log,
|
||||
}, p)
|
||||
})
|
||||
}
|
||||
|
||||
func TestHostVolumePluginExternal(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("skipped because windows") // db TODO(1.10.0)
|
||||
}
|
||||
|
||||
volID := "test-vol-id"
|
||||
tmp := t.TempDir()
|
||||
target := filepath.Join(tmp, volID)
|
||||
|
||||
expectVersion, err := version.NewVersion("0.0.2")
|
||||
must.NoError(t, err)
|
||||
|
||||
t.Run("happy", func(t *testing.T) {
|
||||
|
||||
log, getLogs := logRecorder(t)
|
||||
plug, err := NewHostVolumePluginExternal(log,
|
||||
"./test_fixtures", "test_plugin.sh", tmp, "test-node-pool")
|
||||
must.NoError(t, err)
|
||||
|
||||
// fingerprint
|
||||
v, err := plug.Fingerprint(timeout(t))
|
||||
logged := getLogs()
|
||||
must.NoError(t, err, must.Sprintf("logs: %s", logged))
|
||||
must.Eq(t, expectVersion, v.Version, must.Sprintf("logs: %s", logged))
|
||||
|
||||
// create
|
||||
resp, err := plug.Create(timeout(t),
|
||||
&cstructs.ClientHostVolumeCreateRequest{
|
||||
Name: "test-vol-name",
|
||||
ID: volID,
|
||||
Namespace: "test-namespace",
|
||||
NodeID: "test-node",
|
||||
RequestedCapacityMinBytes: 5,
|
||||
RequestedCapacityMaxBytes: 10,
|
||||
Parameters: map[string]string{"key": "val"},
|
||||
})
|
||||
logged = getLogs()
|
||||
must.NoError(t, err, must.Sprintf("logs: %s", logged))
|
||||
|
||||
must.Eq(t, &HostVolumePluginCreateResponse{
|
||||
Path: target,
|
||||
SizeBytes: 5,
|
||||
}, resp)
|
||||
must.DirExists(t, target)
|
||||
must.StrContains(t, logged, "OPERATION=create") // stderr from `env`
|
||||
must.StrContains(t, logged, `stdout="{`) // stdout from printf
|
||||
|
||||
// delete
|
||||
err = plug.Delete(timeout(t),
|
||||
&cstructs.ClientHostVolumeDeleteRequest{
|
||||
Name: "test-vol-name",
|
||||
ID: volID,
|
||||
HostPath: resp.Path,
|
||||
Namespace: "test-namespace",
|
||||
NodeID: "test-node",
|
||||
Parameters: map[string]string{"key": "val"},
|
||||
})
|
||||
logged = getLogs()
|
||||
must.NoError(t, err, must.Sprintf("logs: %s", logged))
|
||||
must.DirNotExists(t, target)
|
||||
must.StrContains(t, logged, "OPERATION=delete") // stderr from `env`
|
||||
must.StrContains(t, logged, "removed directory") // stdout from `rm -v`
|
||||
})
|
||||
|
||||
t.Run("sad", func(t *testing.T) {
|
||||
|
||||
log, getLogs := logRecorder(t)
|
||||
plug, err := NewHostVolumePluginExternal(log, "./test_fixtures", "test_plugin_sad.sh", tmp, "")
|
||||
must.NoError(t, err)
|
||||
|
||||
v, err := plug.Fingerprint(timeout(t))
|
||||
must.EqError(t, err, `error fingerprinting plugin "test_plugin_sad.sh": exit status 1`)
|
||||
must.Nil(t, v)
|
||||
logged := getLogs()
|
||||
must.StrContains(t, logged, "fingerprint: sad plugin is sad")
|
||||
must.StrContains(t, logged, "fingerprint: it tells you all about it in stderr")
|
||||
|
||||
// reset logger
|
||||
log, getLogs = logRecorder(t)
|
||||
plug.log = log
|
||||
|
||||
resp, err := plug.Create(timeout(t),
|
||||
&cstructs.ClientHostVolumeCreateRequest{
|
||||
ID: volID,
|
||||
})
|
||||
must.EqError(t, err, `error creating volume "test-vol-id" with plugin "test_plugin_sad.sh": exit status 1: create: sad plugin is sad`)
|
||||
must.Nil(t, resp)
|
||||
logged = getLogs()
|
||||
must.StrContains(t, logged, "create: sad plugin is sad")
|
||||
must.StrContains(t, logged, "create: it tells you all about it in stderr")
|
||||
|
||||
log, getLogs = logRecorder(t)
|
||||
plug.log = log
|
||||
|
||||
err = plug.Delete(timeout(t),
|
||||
&cstructs.ClientHostVolumeDeleteRequest{
|
||||
ID: volID,
|
||||
})
|
||||
must.EqError(t, err, `error deleting volume "test-vol-id" with plugin "test_plugin_sad.sh": exit status 1: delete: sad plugin is sad`)
|
||||
logged = getLogs()
|
||||
must.StrContains(t, logged, "delete: sad plugin is sad")
|
||||
must.StrContains(t, logged, "delete: it tells you all about it in stderr")
|
||||
})
|
||||
}
|
||||
-377
@@ -1,377 +0,0 @@
|
||||
// Copyright IBM Corp. 2015, 2025
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package hostvolumemanager
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
|
||||
"github.com/hashicorp/go-hclog"
|
||||
"github.com/hashicorp/go-multierror"
|
||||
cstructs "github.com/hashicorp/nomad/client/structs"
|
||||
"github.com/hashicorp/nomad/helper"
|
||||
"github.com/hashicorp/nomad/nomad/structs"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrPluginNotExists = errors.New("no such plugin")
|
||||
ErrPluginNotExecutable = errors.New("plugin not executable")
|
||||
ErrVolumeNameExists = errors.New("volume name already exists on this node")
|
||||
)
|
||||
|
||||
// HostVolumeStateManager manages the lifecycle of volumes in client state.
|
||||
type HostVolumeStateManager interface {
|
||||
PutDynamicHostVolume(*cstructs.HostVolumeState) error
|
||||
GetDynamicHostVolumes() ([]*cstructs.HostVolumeState, error)
|
||||
DeleteDynamicHostVolume(string) error
|
||||
}
|
||||
|
||||
// Config is used to configure a HostVolumeManager.
|
||||
type Config struct {
|
||||
// PluginDir is where external plugins may be found.
|
||||
PluginDir string
|
||||
|
||||
// VolumesDir is where plugins should place the directory
|
||||
// that will later become a volume's HostPath
|
||||
VolumesDir string
|
||||
|
||||
// NodePool is passed into external plugin execution environment.
|
||||
NodePool string
|
||||
|
||||
// StateMgr manages client state to restore on agent restarts.
|
||||
StateMgr HostVolumeStateManager
|
||||
|
||||
// UpdateNodeVols is run to update the node when a volume is created
|
||||
// or deleted.
|
||||
UpdateNodeVols HostVolumeNodeUpdater
|
||||
}
|
||||
|
||||
// HostVolumeManager executes plugins, manages volume metadata in client state,
|
||||
// and registers volumes with the client node.
|
||||
type HostVolumeManager struct {
|
||||
pluginDir string
|
||||
volumesDir string
|
||||
nodePool string
|
||||
stateMgr HostVolumeStateManager
|
||||
updateNodeVols HostVolumeNodeUpdater
|
||||
builtIns map[string]HostVolumePlugin
|
||||
locker *volLocker
|
||||
log hclog.Logger
|
||||
}
|
||||
|
||||
// NewHostVolumeManager includes default builtin plugins.
|
||||
func NewHostVolumeManager(logger hclog.Logger, config Config) *HostVolumeManager {
|
||||
logger = logger.Named("host_volume_manager")
|
||||
return &HostVolumeManager{
|
||||
pluginDir: config.PluginDir,
|
||||
volumesDir: config.VolumesDir,
|
||||
nodePool: config.NodePool,
|
||||
stateMgr: config.StateMgr,
|
||||
updateNodeVols: config.UpdateNodeVols,
|
||||
builtIns: map[string]HostVolumePlugin{
|
||||
HostVolumePluginMkdirID: &HostVolumePluginMkdir{
|
||||
ID: HostVolumePluginMkdirID,
|
||||
VolumesDir: config.VolumesDir,
|
||||
log: logger.With("plugin_id", HostVolumePluginMkdirID),
|
||||
},
|
||||
},
|
||||
locker: &volLocker{},
|
||||
log: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// Create runs the appropriate plugin for the given request, saves the request
|
||||
// to state, and updates the node with the volume.
|
||||
func (hvm *HostVolumeManager) Create(ctx context.Context,
|
||||
req *cstructs.ClientHostVolumeCreateRequest) (*cstructs.ClientHostVolumeCreateResponse, error) {
|
||||
|
||||
log := hvm.log.With("volume_name", req.Name, "volume_id", req.ID)
|
||||
|
||||
plug, err := hvm.getPlugin(req.PluginID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// can't have two of the same volume name w/ different IDs per client node
|
||||
isNewVolume, err := hvm.locker.lock(req.Name, req.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
pluginResp, err := plug.Create(ctx, req)
|
||||
if err != nil {
|
||||
hvm.locker.release(req.Name)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
volState := &cstructs.HostVolumeState{
|
||||
ID: req.ID,
|
||||
HostPath: pluginResp.Path,
|
||||
CreateReq: req,
|
||||
}
|
||||
if err := hvm.stateMgr.PutDynamicHostVolume(volState); err != nil {
|
||||
// if we fail to write to state on initial create,
|
||||
// delete the volume so it isn't left lying around
|
||||
// without Nomad knowing about it.
|
||||
log.Error("failed to save volume in client state", "error", err)
|
||||
if isNewVolume {
|
||||
log.Error("initial create detected, running delete")
|
||||
delErr := plug.Delete(ctx, &cstructs.ClientHostVolumeDeleteRequest{
|
||||
ID: req.ID,
|
||||
PluginID: req.PluginID,
|
||||
NodeID: req.NodeID,
|
||||
HostPath: hvm.volumesDir,
|
||||
Parameters: req.Parameters,
|
||||
})
|
||||
if delErr != nil {
|
||||
log.Warn("error deleting volume after state store failure", "error", delErr)
|
||||
err = multierror.Append(err, delErr)
|
||||
}
|
||||
// free up the volume name whether delete succeeded or not.
|
||||
hvm.locker.release(req.Name)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
hvm.updateNodeVols(req.Name, genVolConfig(req, pluginResp.Path))
|
||||
|
||||
resp := &cstructs.ClientHostVolumeCreateResponse{
|
||||
VolumeName: req.Name,
|
||||
VolumeID: req.ID,
|
||||
HostPath: pluginResp.Path,
|
||||
CapacityBytes: pluginResp.SizeBytes,
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// Register saves the request to state, and updates the node with the volume.
|
||||
func (hvm *HostVolumeManager) Register(ctx context.Context,
|
||||
req *cstructs.ClientHostVolumeRegisterRequest) error {
|
||||
|
||||
log := hvm.log.With("volume_name", req.Name, "volume_id", req.ID)
|
||||
|
||||
// can't have two of the same volume name w/ different IDs per client node
|
||||
if _, err := hvm.locker.lock(req.Name, req.ID); err != nil {
|
||||
log.Error("duplicate volume name", "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
_, err := os.Stat(req.HostPath)
|
||||
if err != nil {
|
||||
hvm.locker.release(req.Name)
|
||||
err = fmt.Errorf("could not verify host path for %q: %w", req.Name, err)
|
||||
log.Error("error registering volume", "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// generate a stub create request and plugin response for the fingerprint
|
||||
// and client state
|
||||
creq := &cstructs.ClientHostVolumeCreateRequest{
|
||||
ID: req.ID,
|
||||
Name: req.Name,
|
||||
NodeID: req.NodeID,
|
||||
Parameters: req.Parameters,
|
||||
}
|
||||
volState := &cstructs.HostVolumeState{
|
||||
ID: req.ID,
|
||||
CreateReq: creq,
|
||||
HostPath: req.HostPath,
|
||||
}
|
||||
if err := hvm.stateMgr.PutDynamicHostVolume(volState); err != nil {
|
||||
log.Error("failed to save volume in state", "error", err)
|
||||
hvm.locker.release(req.Name)
|
||||
return err
|
||||
}
|
||||
|
||||
hvm.updateNodeVols(req.Name, genVolConfig(creq, req.HostPath))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete runs the appropriate plugin for the given request, removes it from
|
||||
// state, and updates the node to remove the volume.
|
||||
func (hvm *HostVolumeManager) Delete(ctx context.Context,
|
||||
req *cstructs.ClientHostVolumeDeleteRequest) (*cstructs.ClientHostVolumeDeleteResponse, error) {
|
||||
|
||||
if req.PluginID != "" {
|
||||
plug, err := hvm.getPlugin(req.PluginID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = plug.Delete(ctx, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if err := hvm.stateMgr.DeleteDynamicHostVolume(req.ID); err != nil {
|
||||
hvm.log.Error("failed to delete volume in state", "volume_id", req.ID, "error", err)
|
||||
return nil, err // bail so a user may retry
|
||||
}
|
||||
|
||||
// free up volume name for reuse
|
||||
hvm.locker.release(req.Name)
|
||||
|
||||
hvm.updateNodeVols(req.Name, nil)
|
||||
|
||||
resp := &cstructs.ClientHostVolumeDeleteResponse{
|
||||
VolumeName: req.Name,
|
||||
VolumeID: req.ID,
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// getPlugin finds either a built-in plugin or an external plugin.
|
||||
func (hvm *HostVolumeManager) getPlugin(id string) (HostVolumePlugin, error) {
|
||||
if plug, ok := hvm.builtIns[id]; ok {
|
||||
return plug, nil
|
||||
}
|
||||
log := hvm.log.With("plugin_id", id)
|
||||
return NewHostVolumePluginExternal(log, hvm.pluginDir, id, hvm.volumesDir, hvm.nodePool)
|
||||
}
|
||||
|
||||
// restoreFromState loads all volumes from client state and runs Create for
|
||||
// each one, so volumes are restored upon agent restart or host reboot.
|
||||
func (hvm *HostVolumeManager) restoreFromState(ctx context.Context) (VolumeMap, error) {
|
||||
vols, err := hvm.stateMgr.GetDynamicHostVolumes()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
volumes := make(VolumeMap)
|
||||
var mut sync.Mutex
|
||||
|
||||
if len(vols) == 0 {
|
||||
return volumes, nil // nothing to do
|
||||
}
|
||||
|
||||
group := multierror.Group{}
|
||||
for _, vol := range vols {
|
||||
group.Go(func() error {
|
||||
var volCfg *structs.ClientHostVolumeConfig
|
||||
var err error
|
||||
if vol.CreateReq.PluginID == "" {
|
||||
volCfg, err = hvm.restoreForRegister(vol)
|
||||
} else {
|
||||
volCfg, err = hvm.restoreForCreate(ctx, vol)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
mut.Lock()
|
||||
volumes[vol.CreateReq.Name] = volCfg
|
||||
mut.Unlock()
|
||||
return nil
|
||||
})
|
||||
}
|
||||
mErr := group.Wait()
|
||||
return volumes, helper.FlattenMultierror(mErr.ErrorOrNil())
|
||||
}
|
||||
|
||||
// restoreForCreate restores a single volume that was previously created by
|
||||
// Create, by "recreating" the volumes. Plugins have the best knowledge of their
|
||||
// side effects, and they must be idempotent.
|
||||
func (hvm *HostVolumeManager) restoreForCreate(ctx context.Context, vol *cstructs.HostVolumeState) (*structs.ClientHostVolumeConfig, error) {
|
||||
// missing plugins with associated volumes in state are considered
|
||||
// client-stopping errors. they need to be fixed by cluster admins.
|
||||
plug, err := hvm.getPlugin(vol.CreateReq.PluginID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// lock the name so future creates can't produce duplicates.
|
||||
_, err = hvm.locker.lock(vol.CreateReq.Name, vol.CreateReq.ID)
|
||||
// state should never have duplicate vol names, and restore happens
|
||||
// prior to node registration, so new creates shouldn't come in
|
||||
// concurrently, but check for error just in case.
|
||||
if err != nil {
|
||||
hvm.log.Error("error during restore",
|
||||
"volume_name", vol.CreateReq.Name,
|
||||
"volume_id", vol.CreateReq.ID,
|
||||
"error", err)
|
||||
// don't stop the world if it does happen, because an admin
|
||||
// couldn't do anything about it short of wiping client state.
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
resp, err := plug.Create(ctx, vol.CreateReq)
|
||||
if err != nil {
|
||||
// plugin execution errors are only logged
|
||||
hvm.log.Error("failed to restore",
|
||||
"plugin_id", vol.CreateReq.PluginID, "volume_id", vol.ID, "error", err)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return genVolConfig(vol.CreateReq, resp.Path), nil
|
||||
}
|
||||
|
||||
// restoreForRegister restores a single volume that was previously created by
|
||||
// Register, by converting the stored struct. It otherwise behaves the same as
|
||||
// restoreForCreate.
|
||||
func (hvm *HostVolumeManager) restoreForRegister(vol *cstructs.HostVolumeState) (*structs.ClientHostVolumeConfig, error) {
|
||||
_, err := hvm.locker.lock(vol.CreateReq.Name, vol.CreateReq.ID)
|
||||
if err != nil {
|
||||
hvm.log.Error("error during restore",
|
||||
"volume_name", vol.CreateReq.Name,
|
||||
"volume_id", vol.CreateReq.ID,
|
||||
"error", err)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
_, err = os.Stat(vol.HostPath)
|
||||
if err != nil {
|
||||
hvm.log.Error("failed to restore: could not verify host path",
|
||||
"volume_id", vol.ID, "error", err, "path", vol.HostPath)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return genVolConfig(vol.CreateReq, vol.HostPath), nil
|
||||
}
|
||||
|
||||
// genVolConfig generates the host volume config for the node to report as
|
||||
// available to the servers for job scheduling.
|
||||
func genVolConfig(req *cstructs.ClientHostVolumeCreateRequest, hostPath string) *structs.ClientHostVolumeConfig {
|
||||
return &structs.ClientHostVolumeConfig{
|
||||
Name: req.Name,
|
||||
ID: req.ID,
|
||||
Path: hostPath,
|
||||
|
||||
// dynamic volumes, like CSI, have more robust `capabilities`,
|
||||
// so we always set ReadOnly to false, and let the scheduler
|
||||
// decide when to ignore this and check capabilities instead.
|
||||
ReadOnly: false,
|
||||
}
|
||||
}
|
||||
|
||||
// volLocker is used to ensure that volumes on each node are unique by name.
|
||||
// The volume scheduler will prevent this too, but only after node fingerprint,
|
||||
// so we need to protect against concurrent duplicate creates.
|
||||
type volLocker struct {
|
||||
locks sync.Map
|
||||
}
|
||||
|
||||
// lock the provided name, return true if it was not already locked,
|
||||
// and error if it was already locked with a different ID.
|
||||
func (l *volLocker) lock(name, id string) (bool, error) {
|
||||
current, exists := l.locks.LoadOrStore(name, id)
|
||||
if exists && id != current.(string) {
|
||||
return false, ErrVolumeNameExists
|
||||
}
|
||||
return !exists, nil
|
||||
}
|
||||
|
||||
func (l *volLocker) release(name string) {
|
||||
l.locks.Delete(name)
|
||||
}
|
||||
|
||||
func (l *volLocker) isLocked(name string) bool {
|
||||
_, locked := l.locks.Load(name)
|
||||
return locked
|
||||
}
|
||||
-441
@@ -1,441 +0,0 @@
|
||||
// Copyright IBM Corp. 2015, 2025
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package hostvolumemanager
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/hashicorp/go-hclog"
|
||||
"github.com/hashicorp/go-version"
|
||||
cstate "github.com/hashicorp/nomad/client/state"
|
||||
cstructs "github.com/hashicorp/nomad/client/structs"
|
||||
"github.com/hashicorp/nomad/helper/testlog"
|
||||
"github.com/hashicorp/nomad/nomad/structs"
|
||||
"github.com/shoenig/test"
|
||||
"github.com/shoenig/test/must"
|
||||
)
|
||||
|
||||
func TestHostVolumeManager(t *testing.T) {
|
||||
log := testlog.HCLogger(t)
|
||||
errDB := &cstate.ErrDB{}
|
||||
memDB := cstate.NewMemDB(log)
|
||||
node := newFakeNode(t)
|
||||
|
||||
volumesDirCreate := t.TempDir()
|
||||
volumesDirRegister := t.TempDir()
|
||||
|
||||
hvm := NewHostVolumeManager(log, Config{
|
||||
PluginDir: "./test_fixtures",
|
||||
VolumesDir: volumesDirCreate,
|
||||
StateMgr: errDB,
|
||||
UpdateNodeVols: node.updateVol,
|
||||
})
|
||||
|
||||
plug := &fakePlugin{volsDir: volumesDirCreate}
|
||||
hvm.builtIns["test-plugin"] = plug
|
||||
|
||||
ctx := timeout(t)
|
||||
|
||||
t.Run("create", func(t *testing.T) {
|
||||
// plugin doesn't exist
|
||||
name := "created-volume"
|
||||
req := &cstructs.ClientHostVolumeCreateRequest{
|
||||
Name: name,
|
||||
ID: "vol-id-1",
|
||||
PluginID: "nope",
|
||||
|
||||
RequestedCapacityMinBytes: 5,
|
||||
}
|
||||
_, err := hvm.Create(ctx, req)
|
||||
must.ErrorIs(t, err, ErrPluginNotExists)
|
||||
|
||||
// error from plugin
|
||||
req.PluginID = "test-plugin"
|
||||
plug.createErr = errors.New("sad create")
|
||||
_, err = hvm.Create(ctx, req)
|
||||
must.ErrorIs(t, err, plug.createErr)
|
||||
assertNotLocked(t, hvm, name)
|
||||
plug.reset()
|
||||
|
||||
// error saving state, then error from cleanup attempt
|
||||
plug.deleteErr = errors.New("sad delete")
|
||||
_, err = hvm.Create(ctx, req)
|
||||
must.ErrorIs(t, err, cstate.ErrDBError)
|
||||
must.ErrorIs(t, err, plug.deleteErr)
|
||||
assertNotLocked(t, hvm, name)
|
||||
plug.reset()
|
||||
|
||||
// error saving state, successful cleanup
|
||||
_, err = hvm.Create(ctx, req)
|
||||
must.ErrorIs(t, err, cstate.ErrDBError)
|
||||
must.Eq(t, "vol-id-1", plug.deleted)
|
||||
assertNotLocked(t, hvm, name)
|
||||
plug.reset()
|
||||
|
||||
// happy path
|
||||
hvm.stateMgr = memDB
|
||||
resp, err := hvm.Create(ctx, req)
|
||||
must.NoError(t, err)
|
||||
expectResp := &cstructs.ClientHostVolumeCreateResponse{
|
||||
VolumeName: "created-volume",
|
||||
VolumeID: "vol-id-1",
|
||||
HostPath: filepath.Join(volumesDirCreate, "vol-id-1"),
|
||||
CapacityBytes: 5,
|
||||
}
|
||||
must.Eq(t, expectResp, resp)
|
||||
stateDBs, err := memDB.GetDynamicHostVolumes()
|
||||
must.NoError(t, err)
|
||||
// should be saved to state
|
||||
must.Len(t, 1, stateDBs)
|
||||
must.Eq(t, "vol-id-1", stateDBs[0].ID)
|
||||
must.Eq(t, "vol-id-1", stateDBs[0].CreateReq.ID)
|
||||
// should be registered with node
|
||||
must.MapContainsKey(t, node.vols, name, must.Sprintf("no %q in %+v", name, node.vols))
|
||||
assertLocked(t, hvm, name)
|
||||
|
||||
// repeat create with same ID but different size may update the volume
|
||||
req.RequestedCapacityMinBytes = 10
|
||||
expectResp.CapacityBytes = 10
|
||||
resp, err = hvm.Create(ctx, req)
|
||||
must.NoError(t, err)
|
||||
must.Eq(t, expectResp, resp)
|
||||
|
||||
// error saving state on restore/update should not run delete
|
||||
hvm.stateMgr = errDB
|
||||
resp, err = hvm.Create(ctx, req)
|
||||
must.ErrorIs(t, err, cstate.ErrDBError)
|
||||
must.Nil(t, resp)
|
||||
must.Eq(t, "", plug.deleted)
|
||||
plug.reset()
|
||||
hvm.stateMgr = memDB
|
||||
|
||||
// duplicate create with the same vol name but different ID should fail
|
||||
_, err = hvm.Create(ctx, &cstructs.ClientHostVolumeCreateRequest{
|
||||
Name: name,
|
||||
ID: "different-vol-id",
|
||||
PluginID: "test-plugin",
|
||||
})
|
||||
must.ErrorIs(t, err, ErrVolumeNameExists)
|
||||
})
|
||||
|
||||
t.Run("register", func(t *testing.T) {
|
||||
name := "registered-volume"
|
||||
req := &cstructs.ClientHostVolumeRegisterRequest{
|
||||
ID: "vol-id-2",
|
||||
Name: name,
|
||||
HostPath: volumesDirRegister,
|
||||
CapacityBytes: 1000,
|
||||
}
|
||||
err := hvm.Register(ctx, req)
|
||||
must.NoError(t, err)
|
||||
|
||||
// should be saved to state and registered with node
|
||||
stateDBs, err := memDB.GetDynamicHostVolumes()
|
||||
must.NoError(t, err)
|
||||
must.Len(t, 2, stateDBs)
|
||||
sort.Slice(stateDBs, func(i, j int) bool { return stateDBs[i].ID < stateDBs[j].ID })
|
||||
must.Eq(t, "vol-id-2", stateDBs[1].ID)
|
||||
must.Eq(t, "vol-id-2", stateDBs[1].CreateReq.ID)
|
||||
must.MapContainsKey(t, node.vols, name, must.Sprintf("no %q in %+v", name, node.vols))
|
||||
assertLocked(t, hvm, name)
|
||||
})
|
||||
|
||||
// despite being a subtest, this needs to run after "create" and "register"
|
||||
t.Run("delete", func(t *testing.T) {
|
||||
name := "created-volume"
|
||||
// should be locked from "create" above
|
||||
assertLocked(t, hvm, name)
|
||||
|
||||
// plugin doesn't exist
|
||||
req := &cstructs.ClientHostVolumeDeleteRequest{
|
||||
Name: name,
|
||||
ID: "vol-id-1",
|
||||
PluginID: "nope",
|
||||
}
|
||||
_, err := hvm.Delete(ctx, req)
|
||||
must.ErrorIs(t, err, ErrPluginNotExists)
|
||||
assertLocked(t, hvm, name)
|
||||
|
||||
// error from plugin
|
||||
req.PluginID = "test-plugin"
|
||||
plug.deleteErr = errors.New("sad delete")
|
||||
_, err = hvm.Delete(ctx, req)
|
||||
must.ErrorIs(t, err, plug.deleteErr)
|
||||
assertLocked(t, hvm, name)
|
||||
plug.reset()
|
||||
|
||||
// error saving state
|
||||
hvm.stateMgr = errDB
|
||||
_, err = hvm.Delete(ctx, req)
|
||||
must.ErrorIs(t, err, cstate.ErrDBError)
|
||||
assertLocked(t, hvm, name)
|
||||
|
||||
// happy path
|
||||
hvm.stateMgr = memDB
|
||||
|
||||
// and delete it
|
||||
resp, err := hvm.Delete(ctx, req)
|
||||
must.NoError(t, err)
|
||||
must.Eq(t, &cstructs.ClientHostVolumeDeleteResponse{
|
||||
VolumeName: "created-volume",
|
||||
VolumeID: "vol-id-1",
|
||||
}, resp)
|
||||
must.Eq(t, VolumeMap{
|
||||
"registered-volume": &structs.ClientHostVolumeConfig{
|
||||
Name: "registered-volume",
|
||||
Path: volumesDirRegister,
|
||||
ID: "vol-id-2",
|
||||
},
|
||||
}, node.vols, must.Sprint("created-volume should be deleted from node"))
|
||||
stateVols, err := memDB.GetDynamicHostVolumes()
|
||||
must.NoError(t, err)
|
||||
must.Len(t, 1, stateVols, must.Sprint("only one volume should be deleted"))
|
||||
|
||||
assertNotLocked(t, hvm, name)
|
||||
assertLocked(t, hvm, "registered-volume")
|
||||
|
||||
req.Name = "registered-volume"
|
||||
req.ID = "vol-id-2"
|
||||
req.PluginID = ""
|
||||
resp, err = hvm.Delete(ctx, req)
|
||||
must.NoError(t, err)
|
||||
|
||||
must.Eq(t, VolumeMap{}, node.vols, must.Sprint("all volumes should be deleted from node"))
|
||||
stateVols, err = memDB.GetDynamicHostVolumes()
|
||||
must.NoError(t, err)
|
||||
must.Nil(t, stateVols, must.Sprint("all volumes should be deleted"))
|
||||
assertNotLocked(t, hvm, "registered-volume")
|
||||
})
|
||||
}
|
||||
|
||||
type fakePlugin struct {
|
||||
volsDir string
|
||||
created string
|
||||
deleted string
|
||||
fingerprintErr error
|
||||
createErr error
|
||||
deleteErr error
|
||||
}
|
||||
|
||||
func (p *fakePlugin) reset() {
|
||||
p.deleted, p.fingerprintErr, p.createErr, p.deleteErr = "", nil, nil, nil
|
||||
}
|
||||
|
||||
func (p *fakePlugin) Fingerprint(_ context.Context) (*PluginFingerprint, error) {
|
||||
if p.fingerprintErr != nil {
|
||||
return nil, p.fingerprintErr
|
||||
}
|
||||
v, err := version.NewVersion("0.0.1")
|
||||
return &PluginFingerprint{
|
||||
Version: v,
|
||||
}, err
|
||||
}
|
||||
|
||||
func (p *fakePlugin) Create(_ context.Context, req *cstructs.ClientHostVolumeCreateRequest) (*HostVolumePluginCreateResponse, error) {
|
||||
if p.createErr != nil {
|
||||
return nil, p.createErr
|
||||
}
|
||||
p.created = req.ID
|
||||
return &HostVolumePluginCreateResponse{
|
||||
Path: filepath.Join(p.volsDir, req.ID),
|
||||
SizeBytes: req.RequestedCapacityMinBytes,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *fakePlugin) Delete(_ context.Context, req *cstructs.ClientHostVolumeDeleteRequest) error {
|
||||
if p.deleteErr != nil {
|
||||
return p.deleteErr
|
||||
}
|
||||
p.deleted = req.ID
|
||||
return nil
|
||||
}
|
||||
|
||||
func assertLocked(t *testing.T, hvm *HostVolumeManager, name string) {
|
||||
t.Helper()
|
||||
must.True(t, hvm.locker.isLocked(name), must.Sprintf("vol name %q should be locked", name))
|
||||
}
|
||||
|
||||
func assertNotLocked(t *testing.T, hvm *HostVolumeManager, name string) {
|
||||
t.Helper()
|
||||
must.False(t, hvm.locker.isLocked(name), must.Sprintf("vol name %q should not be locked", name))
|
||||
}
|
||||
|
||||
func TestHostVolumeManager_restoreFromState(t *testing.T) {
|
||||
log := testlog.HCLogger(t)
|
||||
hostPath := t.TempDir()
|
||||
|
||||
vol1 := &cstructs.HostVolumeState{
|
||||
ID: "test-vol-id-1",
|
||||
CreateReq: &cstructs.ClientHostVolumeCreateRequest{
|
||||
Name: "created-volume",
|
||||
ID: "test-vol-id-1",
|
||||
PluginID: "mkdir",
|
||||
},
|
||||
}
|
||||
vol2 := &cstructs.HostVolumeState{
|
||||
ID: "test-vol-id-2",
|
||||
HostPath: hostPath,
|
||||
CreateReq: &cstructs.ClientHostVolumeCreateRequest{
|
||||
Name: "registered-volume",
|
||||
ID: "test-vol-id-2",
|
||||
PluginID: "", // this signifies a Register operation
|
||||
},
|
||||
}
|
||||
|
||||
node := newFakeNode(t)
|
||||
|
||||
t.Run("no vols", func(t *testing.T) {
|
||||
state := cstate.NewMemDB(log)
|
||||
hvm := NewHostVolumeManager(log, Config{
|
||||
StateMgr: state,
|
||||
// no other fields are necessary when there are zero volumes
|
||||
})
|
||||
vols, err := hvm.restoreFromState(timeout(t))
|
||||
must.NoError(t, err)
|
||||
must.Eq(t, VolumeMap{}, vols)
|
||||
})
|
||||
|
||||
t.Run("happy", func(t *testing.T) {
|
||||
// put our volume in state
|
||||
state := cstate.NewMemDB(log)
|
||||
must.NoError(t, state.PutDynamicHostVolume(vol1))
|
||||
must.NoError(t, state.PutDynamicHostVolume(vol2))
|
||||
|
||||
// new volume manager should load it from state and run Create,
|
||||
// resulting in a volume directory in this mountDir.
|
||||
volsDir := t.TempDir()
|
||||
volPath := filepath.Join(volsDir, vol1.ID)
|
||||
|
||||
hvm := NewHostVolumeManager(log, Config{
|
||||
StateMgr: state,
|
||||
UpdateNodeVols: node.updateVol,
|
||||
PluginDir: "/wherever",
|
||||
VolumesDir: volsDir,
|
||||
})
|
||||
|
||||
vols, err := hvm.restoreFromState(timeout(t))
|
||||
must.NoError(t, err)
|
||||
|
||||
expect := map[string]*structs.ClientHostVolumeConfig{
|
||||
"created-volume": {
|
||||
Name: "created-volume",
|
||||
ID: "test-vol-id-1",
|
||||
Path: volPath,
|
||||
ReadOnly: false,
|
||||
},
|
||||
"registered-volume": {
|
||||
Name: "registered-volume",
|
||||
ID: "test-vol-id-2",
|
||||
Path: hostPath,
|
||||
ReadOnly: false,
|
||||
},
|
||||
}
|
||||
must.Eq(t, expect, vols)
|
||||
|
||||
must.DirExists(t, volPath)
|
||||
|
||||
assertLocked(t, hvm, "created-volume")
|
||||
assertLocked(t, hvm, "registered-volume")
|
||||
})
|
||||
|
||||
t.Run("state error", func(t *testing.T) {
|
||||
state := &cstate.ErrDB{}
|
||||
hvm := NewHostVolumeManager(log, Config{StateMgr: state})
|
||||
vols, err := hvm.restoreFromState(timeout(t))
|
||||
must.ErrorIs(t, err, cstate.ErrDBError)
|
||||
must.Nil(t, vols)
|
||||
})
|
||||
|
||||
t.Run("plugin missing", func(t *testing.T) {
|
||||
state := cstate.NewMemDB(log)
|
||||
vol := &cstructs.HostVolumeState{
|
||||
CreateReq: &cstructs.ClientHostVolumeCreateRequest{
|
||||
PluginID: "nonexistent-plugin",
|
||||
},
|
||||
}
|
||||
must.NoError(t, state.PutDynamicHostVolume(vol))
|
||||
|
||||
hvm := NewHostVolumeManager(log, Config{StateMgr: state})
|
||||
vols, err := hvm.restoreFromState(timeout(t))
|
||||
must.ErrorIs(t, err, ErrPluginNotExists)
|
||||
must.MapEmpty(t, vols)
|
||||
})
|
||||
|
||||
t.Run("plugin error", func(t *testing.T) {
|
||||
state := cstate.NewMemDB(log)
|
||||
vol := &cstructs.HostVolumeState{
|
||||
ID: "test-volume",
|
||||
CreateReq: &cstructs.ClientHostVolumeCreateRequest{
|
||||
PluginID: "test-plugin",
|
||||
},
|
||||
}
|
||||
must.NoError(t, state.PutDynamicHostVolume(vol))
|
||||
|
||||
log, getLogs := logRecorder(t)
|
||||
hvm := NewHostVolumeManager(log, Config{StateMgr: state})
|
||||
plug := &fakePlugin{
|
||||
createErr: errors.New("sad create"),
|
||||
}
|
||||
hvm.builtIns["test-plugin"] = plug
|
||||
|
||||
vols, err := hvm.restoreFromState(timeout(t))
|
||||
// error during restore should not halt the whole client
|
||||
must.NoError(t, err)
|
||||
must.NotNil(t, vols)
|
||||
// but it should log
|
||||
logs := getLogs()
|
||||
must.StrContains(t, logs, "[ERROR]")
|
||||
must.StrContains(t, logs, `failed to restore: plugin_id=test-plugin volume_id=test-volume error="sad create"`)
|
||||
})
|
||||
}
|
||||
|
||||
type fakeNode struct {
|
||||
vols VolumeMap
|
||||
log hclog.Logger
|
||||
}
|
||||
|
||||
func (n *fakeNode) updateVol(name string, volume *structs.ClientHostVolumeConfig) {
|
||||
UpdateVolumeMap(n.log, n.vols, name, volume)
|
||||
}
|
||||
|
||||
func newFakeNode(t *testing.T) *fakeNode {
|
||||
return &fakeNode{
|
||||
vols: make(VolumeMap),
|
||||
log: testlog.HCLogger(t),
|
||||
}
|
||||
}
|
||||
|
||||
// timeout provides a context that times out in 1 second
|
||||
func timeout(t *testing.T) context.Context {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
t.Cleanup(cancel)
|
||||
return ctx
|
||||
}
|
||||
|
||||
// logRecorder is here so we can assert that stdout/stderr appear in logs
|
||||
func logRecorder(t *testing.T) (hclog.Logger, func() string) {
|
||||
t.Helper()
|
||||
buf := &bytes.Buffer{}
|
||||
logger := hclog.New(&hclog.LoggerOptions{
|
||||
Name: "log-recorder",
|
||||
Output: buf,
|
||||
Level: hclog.Debug,
|
||||
IncludeLocation: true,
|
||||
DisableTime: true,
|
||||
})
|
||||
return logger, func() string {
|
||||
bts, err := io.ReadAll(buf)
|
||||
test.NoError(t, err)
|
||||
buf.Reset()
|
||||
return string(bts)
|
||||
}
|
||||
}
|
||||
-50
@@ -1,50 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Copyright IBM Corp. 2015, 2025
|
||||
# SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
# plugin for host_volume_plugin_test.go
|
||||
set -xeuo pipefail
|
||||
|
||||
env 1>&2
|
||||
|
||||
test "$1" == "$DHV_OPERATION"
|
||||
|
||||
echo 'all operations should ignore stderr' 1>&2
|
||||
|
||||
case $1 in
|
||||
fingerprint)
|
||||
echo '{"version": "0.0.2"}' ;;
|
||||
create)
|
||||
test "$DHV_VOLUME_NAME" == 'test-vol-name'
|
||||
test "$DHV_VOLUME_ID" == 'test-vol-id'
|
||||
test "$DHV_NAMESPACE" == 'test-namespace'
|
||||
test "$DHV_CAPACITY_MIN_BYTES" -eq 5
|
||||
test "$DHV_CAPACITY_MAX_BYTES" -eq 10
|
||||
test "$DHV_NODE_ID" == 'test-node'
|
||||
test "$DHV_NODE_POOL" == 'test-node-pool'
|
||||
test "$DHV_PARAMETERS" == '{"key":"val"}'
|
||||
test "$DHV_PLUGIN_DIR" == './test_fixtures'
|
||||
test -d "$DHV_VOLUMES_DIR"
|
||||
target="$DHV_VOLUMES_DIR/$DHV_VOLUME_ID"
|
||||
test "$target" != '/'
|
||||
mkdir -p "$target"
|
||||
printf '{"path": "%s", "bytes": 5}' "$target"
|
||||
;;
|
||||
delete)
|
||||
test "$DHV_NODE_ID" == 'test-node'
|
||||
test "$DHV_NODE_POOL" == 'test-node-pool'
|
||||
test "$DHV_NAMESPACE" == 'test-namespace'
|
||||
test "$DHV_VOLUME_NAME" == 'test-vol-name'
|
||||
test "$DHV_VOLUME_ID" == 'test-vol-id'
|
||||
test "$DHV_PARAMETERS" == '{"key":"val"}'
|
||||
test "$DHV_PLUGIN_DIR" == './test_fixtures'
|
||||
test -d "$DHV_VOLUMES_DIR"
|
||||
target="$DHV_VOLUMES_DIR/$DHV_VOLUME_ID"
|
||||
test "$target" != '/'
|
||||
test "$DHV_CREATED_PATH" == "$target"
|
||||
rm -rfv "$target"
|
||||
;;
|
||||
*)
|
||||
echo "unknown operation $1"
|
||||
exit 1 ;;
|
||||
esac
|
||||
-7
@@ -1,7 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Copyright IBM Corp. 2015, 2025
|
||||
# SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
printf '{"error": "%s: sad plugin is sad"}' $1
|
||||
echo "$1: it tells you all about it in stderr" 1>&2
|
||||
exit 1
|
||||
-73
@@ -1,73 +0,0 @@
|
||||
// Copyright IBM Corp. 2015, 2025
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package hostvolumemanager
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/hashicorp/go-hclog"
|
||||
"github.com/hashicorp/nomad/nomad/structs"
|
||||
)
|
||||
|
||||
// this file is for fingerprinting *volumes*
|
||||
// *plugins* are detected in client/fingerprint/dynamic_host_volumes.go
|
||||
|
||||
// HostVolumeNodeUpdater is used to add or remove volumes from the Node.
|
||||
type HostVolumeNodeUpdater func(name string, volume *structs.ClientHostVolumeConfig)
|
||||
|
||||
// VolumeMap keys are volume `name`s, identical to Node.HostVolumes.
|
||||
type VolumeMap map[string]*structs.ClientHostVolumeConfig
|
||||
|
||||
// UpdateVolumeMap returns true if it changes the provided `volumes` map.
|
||||
// If `vol` is nil, key `name` will be removed from the map, if present.
|
||||
// If it is not nil, `name: vol` will be set on the map, if different.
|
||||
//
|
||||
// Since it may mutate the map, the caller should make a copy
|
||||
// or acquire a lock as appropriate for their context.
|
||||
func UpdateVolumeMap(log hclog.Logger, volumes VolumeMap, name string, vol *structs.ClientHostVolumeConfig) (changed bool) {
|
||||
current, exists := volumes[name]
|
||||
if vol == nil {
|
||||
if exists {
|
||||
delete(volumes, name)
|
||||
changed = true
|
||||
}
|
||||
} else {
|
||||
// if the volume already exists with no ID, it will be because it was
|
||||
// added to client agent config after having been previously created
|
||||
// as a dynamic vol. dynamic takes precedence, but log a warning.
|
||||
if exists && current.ID == "" {
|
||||
log.Warn("overriding static host volume with dynamic", "name", name, "id", vol.ID)
|
||||
}
|
||||
if !exists || !vol.Equal(current) {
|
||||
volumes[name] = vol
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
// WaitForFirstFingerprint implements client.FingerprintingPluginManager
|
||||
// so any existing volumes are added to the client node on agent start.
|
||||
func (hvm *HostVolumeManager) WaitForFirstFingerprint(ctx context.Context) <-chan struct{} {
|
||||
// the fingerprint manager puts batchFirstFingerprintsTimeout (50 seconds)
|
||||
// on the context that it sends to us here so we don't need another
|
||||
// timeout. we just need to cancel to report when we are done.
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
volumes, err := hvm.restoreFromState(ctx)
|
||||
if err != nil {
|
||||
hvm.log.Error("failed to restore state", "error", err)
|
||||
return ctx.Done()
|
||||
}
|
||||
for name, vol := range volumes {
|
||||
hvm.updateNodeVols(name, vol) // => batchNodeUpdates.updateNodeFromHostVolume()
|
||||
}
|
||||
return ctx.Done()
|
||||
}
|
||||
func (hvm *HostVolumeManager) Run() {}
|
||||
func (hvm *HostVolumeManager) Shutdown() {}
|
||||
func (hvm *HostVolumeManager) PluginType() string {
|
||||
// "Plugin"Type is misleading, because this is for *volumes* but ok.
|
||||
return "dynamic_host_volume"
|
||||
}
|
||||
-144
@@ -1,144 +0,0 @@
|
||||
// Copyright IBM Corp. 2015, 2025
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package hostvolumemanager
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/hashicorp/nomad/client/state"
|
||||
cstructs "github.com/hashicorp/nomad/client/structs"
|
||||
"github.com/hashicorp/nomad/helper/testlog"
|
||||
"github.com/hashicorp/nomad/nomad/structs"
|
||||
"github.com/shoenig/test/must"
|
||||
)
|
||||
|
||||
func TestUpdateVolumeMap(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
|
||||
vols VolumeMap
|
||||
volName string
|
||||
vol *structs.ClientHostVolumeConfig
|
||||
|
||||
expectMap VolumeMap
|
||||
expectChange bool
|
||||
|
||||
expectLog string
|
||||
}{
|
||||
{
|
||||
name: "delete absent",
|
||||
vols: VolumeMap{},
|
||||
volName: "anything",
|
||||
vol: nil,
|
||||
expectMap: VolumeMap{},
|
||||
expectChange: false,
|
||||
},
|
||||
{
|
||||
name: "delete present",
|
||||
vols: VolumeMap{"deleteme": {}},
|
||||
volName: "deleteme",
|
||||
vol: nil,
|
||||
expectMap: VolumeMap{},
|
||||
expectChange: true,
|
||||
},
|
||||
{
|
||||
name: "add absent",
|
||||
vols: VolumeMap{},
|
||||
volName: "addme",
|
||||
vol: &structs.ClientHostVolumeConfig{},
|
||||
expectMap: VolumeMap{"addme": {}},
|
||||
expectChange: true,
|
||||
},
|
||||
{
|
||||
name: "add present",
|
||||
vols: VolumeMap{"ignoreme": {}},
|
||||
volName: "ignoreme",
|
||||
vol: &structs.ClientHostVolumeConfig{},
|
||||
expectMap: VolumeMap{"ignoreme": {}},
|
||||
expectChange: false,
|
||||
},
|
||||
{
|
||||
// this should not happen with dynamic vols, but test anyway
|
||||
name: "change present",
|
||||
vols: VolumeMap{"changeme": {ID: "before"}},
|
||||
volName: "changeme",
|
||||
vol: &structs.ClientHostVolumeConfig{ID: "after"},
|
||||
expectMap: VolumeMap{"changeme": {ID: "after"}},
|
||||
expectChange: true,
|
||||
},
|
||||
{
|
||||
// this should only happen during agent start, if a static vol has
|
||||
// been added to config after a previous dynamic vol was created
|
||||
// with the same name.
|
||||
name: "override static",
|
||||
vols: VolumeMap{"overrideme": {ID: ""}}, // static vols have no ID
|
||||
volName: "overrideme",
|
||||
vol: &structs.ClientHostVolumeConfig{ID: "dynamic-vol-id"},
|
||||
expectMap: VolumeMap{"overrideme": {ID: "dynamic-vol-id"}},
|
||||
expectChange: true,
|
||||
expectLog: "overriding static host volume with dynamic: name=overrideme id=dynamic-vol-id",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
log, getLogs := logRecorder(t)
|
||||
|
||||
changed := UpdateVolumeMap(log, tc.vols, tc.volName, tc.vol)
|
||||
must.Eq(t, tc.expectMap, tc.vols)
|
||||
|
||||
if tc.expectChange {
|
||||
must.True(t, changed, must.Sprint("expect volume to have been changed"))
|
||||
} else {
|
||||
must.False(t, changed, must.Sprint("expect volume not to have been changed"))
|
||||
}
|
||||
|
||||
must.StrContains(t, getLogs(), tc.expectLog)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitForFirstFingerprint(t *testing.T) {
|
||||
log := testlog.HCLogger(t)
|
||||
tmp := t.TempDir()
|
||||
memDB := state.NewMemDB(log)
|
||||
node := newFakeNode(t)
|
||||
hvm := NewHostVolumeManager(log, Config{
|
||||
PluginDir: "",
|
||||
VolumesDir: tmp,
|
||||
StateMgr: memDB,
|
||||
UpdateNodeVols: node.updateVol,
|
||||
})
|
||||
plug := &fakePlugin{volsDir: tmp}
|
||||
hvm.builtIns = map[string]HostVolumePlugin{
|
||||
"test-plugin": plug,
|
||||
}
|
||||
must.NoError(t, memDB.PutDynamicHostVolume(&cstructs.HostVolumeState{
|
||||
ID: "vol-id",
|
||||
CreateReq: &cstructs.ClientHostVolumeCreateRequest{
|
||||
ID: "vol-id",
|
||||
Name: "vol-name",
|
||||
PluginID: "test-plugin",
|
||||
},
|
||||
}))
|
||||
|
||||
ctx := timeout(t)
|
||||
done := hvm.WaitForFirstFingerprint(ctx)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
t.Fatal("fingerprint timed out")
|
||||
case <-done:
|
||||
}
|
||||
|
||||
must.Eq(t, "vol-id", plug.created)
|
||||
must.Eq(t, VolumeMap{
|
||||
"vol-name": &structs.ClientHostVolumeConfig{
|
||||
Name: "vol-name",
|
||||
ID: "vol-id",
|
||||
Path: filepath.Join(tmp, "vol-id"),
|
||||
ReadOnly: false,
|
||||
},
|
||||
}, node.vols)
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
// Copyright IBM Corp. 2015, 2025
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package nomad
|
||||
|
||||
import (
|
||||
"github.com/hashicorp/nomad/acl"
|
||||
"github.com/hashicorp/nomad/nomad/structs"
|
||||
)
|
||||
|
||||
func (s *Server) Authenticate(ctx *RPCContext, args structs.RequestWithIdentity) error {
|
||||
return s.auth.Authenticate(ctx, args)
|
||||
}
|
||||
|
||||
func (s *Server) AuthenticateServerOnly(ctx *RPCContext, args structs.RequestWithIdentity) (*acl.ACL, error) {
|
||||
return s.auth.AuthenticateServerOnly(ctx, args)
|
||||
}
|
||||
|
||||
func (s *Server) AuthenticateNodeIdentityGenerator(ctx *RPCContext, args structs.RequestWithIdentity) error {
|
||||
return s.auth.AuthenticateNodeIdentityGenerator(ctx, args)
|
||||
}
|
||||
|
||||
func (s *Server) AuthenticateClientOnly(ctx *RPCContext, args structs.RequestWithIdentity) (*acl.ACL, error) {
|
||||
return s.auth.AuthenticateClientOnly(ctx, args)
|
||||
}
|
||||
|
||||
func (s *Server) ResolveAuthorizedClientNodePoolByNodeID(aclObj *acl.ACL, nodeID string) (string, error) {
|
||||
return s.auth.ResolveAuthorizedClientNodePoolByNodeID(aclObj, nodeID)
|
||||
}
|
||||
|
||||
func (s *Server) AuthorizeClientAllocation(
|
||||
aclObj *acl.ACL,
|
||||
alloc *structs.Allocation,
|
||||
allowNsOp func(*acl.ACL, string) bool,
|
||||
) error {
|
||||
return s.auth.AuthorizeClientAllocation(aclObj, alloc, allowNsOp)
|
||||
}
|
||||
|
||||
func (s *Server) ResolveAuthorizedClientNodePoolByServiceRegistrationID(
|
||||
aclObj *acl.ACL,
|
||||
namespace, id string,
|
||||
) (string, error) {
|
||||
return s.auth.ResolveAuthorizedClientNodePoolByServiceRegistrationID(aclObj, namespace, id)
|
||||
}
|
||||
|
||||
func (s *Server) ResolveACL(args structs.RequestWithIdentity) (*acl.ACL, error) {
|
||||
return s.auth.ResolveACL(args)
|
||||
}
|
||||
|
||||
func (s *Server) VerifyClaim(token string) (*structs.IdentityClaims, error) {
|
||||
return s.auth.VerifyClaim(token)
|
||||
}
|
||||
|
||||
func (s *Server) ResolvePoliciesForClaims(claims *structs.IdentityClaims) ([]*structs.ACLPolicy, error) {
|
||||
return s.auth.ResolvePoliciesForClaims(claims)
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -1,329 +0,0 @@
|
||||
// Copyright IBM Corp. 2015, 2025
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package nomad
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
msgpackrpc "github.com/hashicorp/net-rpc-msgpackrpc/v2"
|
||||
"github.com/hashicorp/nomad/ci"
|
||||
"github.com/hashicorp/nomad/helper/uuid"
|
||||
"github.com/hashicorp/nomad/nomad/mock"
|
||||
"github.com/hashicorp/nomad/nomad/structs"
|
||||
"github.com/hashicorp/nomad/nomad/structs/config"
|
||||
"github.com/hashicorp/nomad/testutil"
|
||||
"github.com/shoenig/test"
|
||||
"github.com/shoenig/test/must"
|
||||
)
|
||||
|
||||
func TestAuthenticate_mTLS(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
|
||||
// Set up a cluster with mTLS and ACLs
|
||||
|
||||
dir := t.TempDir()
|
||||
|
||||
tlsCfg := &config.TLSConfig{
|
||||
EnableHTTP: true,
|
||||
EnableRPC: true,
|
||||
VerifyServerHostname: true,
|
||||
CAFile: "../helper/tlsutil/testdata/nomad-agent-ca.pem",
|
||||
CertFile: "../helper/tlsutil/testdata/regionFoo-server-nomad.pem",
|
||||
KeyFile: "../helper/tlsutil/testdata/regionFoo-server-nomad-key.pem",
|
||||
}
|
||||
clientTLSCfg := tlsCfg.Copy()
|
||||
clientTLSCfg.CertFile = "../helper/tlsutil/testdata/regionFoo-client-nomad.pem"
|
||||
clientTLSCfg.KeyFile = "../helper/tlsutil/testdata/regionFoo-client-nomad-key.pem"
|
||||
|
||||
setCfg := func(name string, bootstrapExpect int) func(*Config) {
|
||||
return func(c *Config) {
|
||||
c.Region = "regionFoo"
|
||||
c.AuthoritativeRegion = "regionFoo"
|
||||
c.ACLEnabled = true
|
||||
c.BootstrapExpect = bootstrapExpect
|
||||
c.NumSchedulers = 0
|
||||
c.DevMode = false
|
||||
c.DataDir = path.Join(dir, name)
|
||||
c.TLSConfig = tlsCfg
|
||||
}
|
||||
}
|
||||
|
||||
leader, cleanupLeader := TestServer(t, setCfg("node1", 1))
|
||||
defer cleanupLeader()
|
||||
testutil.WaitForLeader(t, leader.RPC)
|
||||
|
||||
follower, cleanupFollower := TestServer(t, setCfg("node2", 0))
|
||||
defer cleanupFollower()
|
||||
|
||||
TestJoin(t, leader, follower)
|
||||
testutil.WaitForLeader(t, leader.RPC)
|
||||
|
||||
testutil.Wait(t, func() (bool, error) {
|
||||
cs, err := follower.encrypter.activeCipherSet()
|
||||
return cs != nil, err
|
||||
})
|
||||
|
||||
rootToken := uuid.Generate()
|
||||
var bootstrapResp *structs.ACLTokenUpsertResponse
|
||||
|
||||
codec := rpcClientWithTLS(t, follower, tlsCfg)
|
||||
must.NoError(t, msgpackrpc.CallWithCodec(codec,
|
||||
"ACL.Bootstrap", &structs.ACLTokenBootstrapRequest{
|
||||
BootstrapSecret: rootToken,
|
||||
WriteRequest: structs.WriteRequest{Region: "regionFoo"},
|
||||
}, &bootstrapResp))
|
||||
must.NotNil(t, bootstrapResp)
|
||||
must.Len(t, 1, bootstrapResp.Tokens)
|
||||
rootAccessor := bootstrapResp.Tokens[0].AccessorID
|
||||
|
||||
// create some ACL tokens directly into raft so we can bypass RPC validation
|
||||
// around expiration times
|
||||
|
||||
token1 := mock.ACLToken()
|
||||
token2 := mock.ACLToken()
|
||||
expireTime := time.Now().Add(time.Second * -10)
|
||||
token2.ExpirationTime = &expireTime
|
||||
|
||||
_, _, err := leader.raftApply(structs.ACLTokenUpsertRequestType,
|
||||
&structs.ACLTokenUpsertRequest{Tokens: []*structs.ACLToken{token1, token2}})
|
||||
must.NoError(t, err)
|
||||
|
||||
// create a node so we can test client RPCs
|
||||
|
||||
node := mock.Node()
|
||||
nodeRegisterReq := &structs.NodeRegisterRequest{
|
||||
Node: node,
|
||||
WriteRequest: structs.WriteRequest{Region: "regionFoo"},
|
||||
}
|
||||
var nodeRegisterResp structs.NodeUpdateResponse
|
||||
|
||||
must.NoError(t, msgpackrpc.CallWithCodec(codec,
|
||||
"Node.Register", nodeRegisterReq, &nodeRegisterResp))
|
||||
must.NotNil(t, bootstrapResp)
|
||||
|
||||
// create some allocations so we can test WorkloadIdentity claims. we'll
|
||||
// create directly into raft so we can bypass RPC validation and the whole
|
||||
// eval, plan, etc. workflow.
|
||||
job := mock.Job()
|
||||
|
||||
_, _, err = leader.raftApply(structs.JobRegisterRequestType,
|
||||
&structs.JobRegisterRequest{Job: job})
|
||||
must.NoError(t, err)
|
||||
|
||||
alloc1 := mock.Alloc()
|
||||
alloc1.NodeID = node.ID
|
||||
alloc1.ClientStatus = structs.AllocClientStatusFailed
|
||||
alloc1.Job = job
|
||||
alloc1.JobID = job.ID
|
||||
|
||||
alloc2 := mock.Alloc()
|
||||
alloc2.NodeID = node.ID
|
||||
alloc2.Job = job
|
||||
alloc2.JobID = job.ID
|
||||
alloc2.ClientStatus = structs.AllocClientStatusRunning
|
||||
|
||||
wiHandle := &structs.WIHandle{
|
||||
WorkloadIdentifier: "web",
|
||||
WorkloadType: structs.WorkloadTypeTask,
|
||||
}
|
||||
|
||||
task1 := alloc1.LookupTask("web")
|
||||
claims1 := structs.NewIdentityClaimsBuilder(job, alloc1,
|
||||
wiHandle,
|
||||
task1.Identity).
|
||||
WithTask(task1).
|
||||
Build(time.Now())
|
||||
|
||||
claims1Token, _, err := leader.encrypter.SignClaims(claims1)
|
||||
must.NoError(t, err, must.Sprint("could not sign claims"))
|
||||
|
||||
task2 := alloc2.LookupTask("web")
|
||||
claims2 := structs.NewIdentityClaimsBuilder(job, alloc2,
|
||||
wiHandle,
|
||||
task2.Identity).
|
||||
WithTask(task1).
|
||||
Build(time.Now())
|
||||
|
||||
claims2Token, _, err := leader.encrypter.SignClaims(claims2)
|
||||
must.NoError(t, err, must.Sprint("could not sign claims"))
|
||||
|
||||
planReq := &structs.ApplyPlanResultsRequest{
|
||||
AllocsUpdated: []*structs.Allocation{alloc1, alloc2},
|
||||
Job: job,
|
||||
}
|
||||
_, _, err = leader.raftApply(structs.ApplyPlanResultsRequestType, planReq)
|
||||
must.NoError(t, err)
|
||||
|
||||
testutil.WaitForResult(func() (bool, error) {
|
||||
store := follower.fsm.State()
|
||||
alloc, err := store.AllocByID(nil, alloc1.ID)
|
||||
return alloc != nil, err
|
||||
}, func(err error) {
|
||||
t.Fatalf("alloc was not replicated via raft: %v", err) // should never happen
|
||||
})
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
tlsCfg *config.TLSConfig
|
||||
stale bool
|
||||
testToken string
|
||||
expectAccessor string
|
||||
expectClientID string
|
||||
expectAllocID string
|
||||
expectTLSName string
|
||||
expectIP string
|
||||
expectErr string
|
||||
expectIDKey string
|
||||
sendFromPeer *Server
|
||||
}{
|
||||
{
|
||||
name: "root token",
|
||||
tlsCfg: clientTLSCfg, // TODO: this is a mixed use cert
|
||||
testToken: rootToken,
|
||||
expectAccessor: rootAccessor,
|
||||
expectIDKey: fmt.Sprintf("token:%s", rootAccessor),
|
||||
},
|
||||
{
|
||||
name: "from peer to leader without token", // ex. Eval.Dequeue
|
||||
tlsCfg: tlsCfg,
|
||||
expectTLSName: "server.regionFoo.nomad",
|
||||
expectAccessor: "anonymous",
|
||||
expectIP: follower.GetConfig().RPCAddr.IP.String(),
|
||||
sendFromPeer: follower,
|
||||
expectIDKey: "token:anonymous",
|
||||
},
|
||||
{
|
||||
// note: this test is somewhat bogus because under test all the
|
||||
// servers share the same IP address with the RPC client
|
||||
name: "anonymous forwarded from peer to leader",
|
||||
tlsCfg: tlsCfg,
|
||||
expectAccessor: "anonymous",
|
||||
expectTLSName: "server.regionFoo.nomad",
|
||||
expectIP: "127.0.0.1",
|
||||
expectIDKey: "token:anonymous",
|
||||
},
|
||||
{
|
||||
name: "invalid token",
|
||||
tlsCfg: clientTLSCfg,
|
||||
testToken: uuid.Generate(),
|
||||
expectTLSName: "server.regionFoo.nomad",
|
||||
expectIP: follower.GetConfig().RPCAddr.IP.String(),
|
||||
expectIDKey: "server.regionFoo.nomad:127.0.0.1",
|
||||
expectErr: "rpc error: Permission denied",
|
||||
},
|
||||
{
|
||||
name: "from peer to leader with leader ACL", // ex. core job GC
|
||||
tlsCfg: tlsCfg,
|
||||
testToken: leader.getLeaderAcl(),
|
||||
expectTLSName: "server.regionFoo.nomad",
|
||||
expectAccessor: "leader",
|
||||
expectIP: follower.GetConfig().RPCAddr.IP.String(),
|
||||
sendFromPeer: follower,
|
||||
expectIDKey: "token:leader",
|
||||
},
|
||||
{
|
||||
name: "from client", // ex. Node.GetAllocs
|
||||
tlsCfg: clientTLSCfg,
|
||||
testToken: node.SecretID,
|
||||
expectClientID: node.ID,
|
||||
expectIDKey: fmt.Sprintf("client:%s", node.ID),
|
||||
},
|
||||
{
|
||||
name: "from client missing secret", // ex. Node.Register
|
||||
tlsCfg: clientTLSCfg,
|
||||
expectAccessor: "anonymous",
|
||||
expectTLSName: "server.regionFoo.nomad",
|
||||
expectIP: follower.GetConfig().RPCAddr.IP.String(),
|
||||
},
|
||||
{
|
||||
name: "from failed workload", // ex. Variables.List
|
||||
tlsCfg: clientTLSCfg,
|
||||
testToken: claims1Token,
|
||||
expectErr: "rpc error: allocation is terminal",
|
||||
},
|
||||
{
|
||||
name: "from running workload", // ex. Variables.List
|
||||
tlsCfg: clientTLSCfg,
|
||||
testToken: claims2Token,
|
||||
expectAllocID: alloc2.ID,
|
||||
expectIDKey: fmt.Sprintf("alloc:%s", alloc2.ID),
|
||||
},
|
||||
{
|
||||
name: "valid user token",
|
||||
tlsCfg: clientTLSCfg,
|
||||
testToken: token1.SecretID,
|
||||
expectAccessor: token1.AccessorID,
|
||||
expectIDKey: fmt.Sprintf("token:%s", token1.AccessorID),
|
||||
},
|
||||
{
|
||||
name: "expired user token",
|
||||
tlsCfg: clientTLSCfg,
|
||||
testToken: token2.SecretID,
|
||||
expectErr: "rpc error: ACL token expired",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
|
||||
req := &structs.GenericRequest{
|
||||
QueryOptions: structs.QueryOptions{
|
||||
Region: "regionFoo",
|
||||
AllowStale: tc.stale,
|
||||
AuthToken: tc.testToken,
|
||||
},
|
||||
}
|
||||
var resp structs.ACLWhoAmIResponse
|
||||
var err error
|
||||
|
||||
if tc.sendFromPeer != nil {
|
||||
aclEndpoint := NewACLEndpoint(tc.sendFromPeer, nil)
|
||||
err = aclEndpoint.WhoAmI(req, &resp)
|
||||
} else {
|
||||
err = msgpackrpc.CallWithCodec(codec, "ACL.WhoAmI", req, &resp)
|
||||
}
|
||||
|
||||
if tc.expectErr != "" {
|
||||
must.EqError(t, err, tc.expectErr)
|
||||
return
|
||||
}
|
||||
|
||||
must.NoError(t, err)
|
||||
must.NotNil(t, resp)
|
||||
must.NotNil(t, resp.Identity)
|
||||
|
||||
if tc.expectIDKey != "" {
|
||||
must.Eq(t, tc.expectIDKey, resp.Identity.String(),
|
||||
must.Sprintf("expected identity key for metrics to match"))
|
||||
}
|
||||
|
||||
if tc.expectAccessor != "" {
|
||||
must.NotNil(t, resp.Identity.ACLToken, must.Sprint("expected ACL token"))
|
||||
test.Eq(t, tc.expectAccessor, resp.Identity.ACLToken.AccessorID,
|
||||
test.Sprint("expected ACL token accessor ID"))
|
||||
}
|
||||
|
||||
test.Eq(t, tc.expectClientID, resp.Identity.ClientID,
|
||||
test.Sprint("expected client ID"))
|
||||
|
||||
if tc.expectAllocID != "" {
|
||||
must.NotNil(t, resp.Identity.Claims, must.Sprint("expected claims"))
|
||||
test.Eq(t, tc.expectAllocID, resp.Identity.Claims.AllocationID,
|
||||
test.Sprint("expected workload identity"))
|
||||
}
|
||||
|
||||
test.Eq(t, tc.expectTLSName, resp.Identity.TLSName, test.Sprint("expected TLS name"))
|
||||
|
||||
if tc.expectIP == "" {
|
||||
test.Nil(t, resp.Identity.RemoteIP, test.Sprint("expected no remote IP"))
|
||||
} else {
|
||||
test.Eq(t, tc.expectIP, resp.Identity.RemoteIP.String())
|
||||
}
|
||||
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,696 +0,0 @@
|
||||
// Copyright IBM Corp. 2015, 2025
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package nomad
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/hashicorp/go-hclog"
|
||||
"github.com/hashicorp/go-memdb"
|
||||
metrics "github.com/hashicorp/go-metrics/compat"
|
||||
|
||||
"github.com/hashicorp/nomad/acl"
|
||||
"github.com/hashicorp/nomad/helper/pointer"
|
||||
"github.com/hashicorp/nomad/helper/uuid"
|
||||
"github.com/hashicorp/nomad/nomad/state"
|
||||
"github.com/hashicorp/nomad/nomad/state/paginator"
|
||||
"github.com/hashicorp/nomad/nomad/structs"
|
||||
)
|
||||
|
||||
// Alloc endpoint is used for manipulating allocations
|
||||
type Alloc struct {
|
||||
srv *Server
|
||||
ctx *RPCContext
|
||||
logger hclog.Logger
|
||||
}
|
||||
|
||||
func NewAllocEndpoint(srv *Server, ctx *RPCContext) *Alloc {
|
||||
return &Alloc{srv: srv, ctx: ctx, logger: srv.logger.Named("alloc")}
|
||||
}
|
||||
|
||||
// List is used to list the allocations in the system
|
||||
func (a *Alloc) List(args *structs.AllocListRequest, reply *structs.AllocListResponse) error {
|
||||
authErr := a.srv.Authenticate(a.ctx, args)
|
||||
if done, err := a.srv.forward("Alloc.List", args, args, reply); done {
|
||||
return err
|
||||
}
|
||||
a.srv.MeasureRPCRate("alloc", structs.RateMetricList, args)
|
||||
if authErr != nil {
|
||||
return structs.ErrPermissionDenied
|
||||
}
|
||||
defer metrics.MeasureSince([]string{"nomad", "alloc", "list"}, time.Now())
|
||||
|
||||
namespace := args.RequestNamespace()
|
||||
|
||||
// Check namespace read-job permissions
|
||||
aclObj, err := a.srv.ResolveACL(args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !aclObj.AllowNsOp(namespace, acl.NamespaceCapabilityReadJob) {
|
||||
return structs.ErrPermissionDenied
|
||||
}
|
||||
allow := aclObj.AllowNsOpFunc(acl.NamespaceCapabilityReadJob)
|
||||
|
||||
// Setup the blocking query
|
||||
sort := state.SortOption(args.Reverse)
|
||||
opts := blockingOptions{
|
||||
queryOpts: &args.QueryOptions,
|
||||
queryMeta: &reply.QueryMeta,
|
||||
run: func(ws memdb.WatchSet, state *state.StateStore) error {
|
||||
// Scan all the allocations
|
||||
var err error
|
||||
var iter memdb.ResultIterator
|
||||
|
||||
// get list of accessible namespaces
|
||||
allowableNamespaces, err := allowedNSes(aclObj, state, allow)
|
||||
if err == structs.ErrPermissionDenied {
|
||||
// return empty allocation if token is not authorized for any
|
||||
// namespace, matching other endpoints
|
||||
reply.Allocations = make([]*structs.AllocListStub, 0)
|
||||
} else if err != nil {
|
||||
return err
|
||||
} else {
|
||||
var tokenizer paginator.Tokenizer[*structs.Allocation]
|
||||
|
||||
if prefix := args.QueryOptions.Prefix; prefix != "" {
|
||||
iter, err = state.AllocsByIDPrefix(ws, namespace, prefix, sort)
|
||||
tokenizer = paginator.IDTokenizer[*structs.Allocation](args.NextToken)
|
||||
} else if namespace != structs.AllNamespacesSentinel {
|
||||
iter, err = state.AllocsByNamespaceOrdered(ws, namespace, sort)
|
||||
tokenizer = paginator.CreateIndexAndIDTokenizer[*structs.Allocation](args.NextToken)
|
||||
} else {
|
||||
iter, err = state.Allocs(ws, sort)
|
||||
tokenizer = paginator.CreateIndexAndIDTokenizer[*structs.Allocation](args.NextToken)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
pager, err := paginator.NewPaginator(iter, args.QueryOptions,
|
||||
paginator.NamespaceSelectorFunc[*structs.Allocation](allowableNamespaces),
|
||||
tokenizer,
|
||||
func(a *structs.Allocation) (*structs.AllocListStub, error) {
|
||||
return a.Stub(args.Fields), nil
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return structs.NewErrRPCCodedf(
|
||||
http.StatusBadRequest, "failed to create result paginator: %v", err)
|
||||
}
|
||||
|
||||
stubs, nextToken, err := pager.Page()
|
||||
if err != nil {
|
||||
return structs.NewErrRPCCodedf(
|
||||
http.StatusBadRequest, "failed to read result page: %v", err)
|
||||
}
|
||||
|
||||
reply.QueryMeta.NextToken = nextToken
|
||||
reply.Allocations = stubs
|
||||
}
|
||||
|
||||
// Use the last index that affected the allocs table
|
||||
index, err := state.Index("allocs")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reply.Index = index
|
||||
|
||||
// Set the query response
|
||||
a.srv.setQueryMeta(&reply.QueryMeta)
|
||||
return nil
|
||||
}}
|
||||
return a.srv.blockingRPC(&opts)
|
||||
}
|
||||
|
||||
// GetAlloc is used to lookup a particular allocation
|
||||
func (a *Alloc) GetAlloc(args *structs.AllocSpecificRequest,
|
||||
reply *structs.SingleAllocResponse) error {
|
||||
authErr := a.srv.Authenticate(a.ctx, args)
|
||||
if done, err := a.srv.forward("Alloc.GetAlloc", args, args, reply); done {
|
||||
return err
|
||||
}
|
||||
a.srv.MeasureRPCRate("alloc", structs.RateMetricRead, args)
|
||||
if authErr != nil {
|
||||
return structs.ErrPermissionDenied
|
||||
}
|
||||
defer metrics.MeasureSince([]string{"nomad", "alloc", "get_alloc"}, time.Now())
|
||||
|
||||
// Check namespace read-job permissions before performing blocking query.
|
||||
allowNsOp := acl.NamespaceValidator(acl.NamespaceCapabilityReadJob)
|
||||
aclObj, err := a.srv.ResolveACL(args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Setup the blocking query
|
||||
opts := blockingOptions{
|
||||
queryOpts: &args.QueryOptions,
|
||||
queryMeta: &reply.QueryMeta,
|
||||
run: func(ws memdb.WatchSet, state *state.StateStore) error {
|
||||
// Lookup the allocation
|
||||
out, err := state.AllocByID(ws, args.AllocID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Setup the output
|
||||
if out != nil {
|
||||
if out.SignedIdentities != nil {
|
||||
out = out.Sanitize()
|
||||
}
|
||||
reply.Alloc = out
|
||||
|
||||
// Re-check namespace in case it differs from request.
|
||||
if err := a.srv.AuthorizeClientAllocation(aclObj, out, allowNsOp); err != nil {
|
||||
return structs.NewErrUnknownAllocation(args.AllocID)
|
||||
}
|
||||
|
||||
reply.Index = out.ModifyIndex
|
||||
} else {
|
||||
// Use the last index that affected the allocs table
|
||||
index, err := state.Index("allocs")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reply.Index = index
|
||||
}
|
||||
|
||||
// Set the query response
|
||||
a.srv.setQueryMeta(&reply.QueryMeta)
|
||||
return nil
|
||||
}}
|
||||
return a.srv.blockingRPC(&opts)
|
||||
}
|
||||
|
||||
// GetAllocs is used to lookup a set of allocations
|
||||
func (a *Alloc) GetAllocs(args *structs.AllocsGetRequest,
|
||||
reply *structs.AllocsGetResponse) error {
|
||||
|
||||
aclObj, err := a.srv.AuthenticateClientOnly(a.ctx, args)
|
||||
if done, err := a.srv.forward("Alloc.GetAllocs", args, args, reply); done {
|
||||
return err
|
||||
}
|
||||
a.srv.MeasureRPCRate("alloc", structs.RateMetricWrite, args)
|
||||
if err != nil {
|
||||
return structs.ErrPermissionDenied
|
||||
}
|
||||
defer metrics.MeasureSince([]string{"nomad", "alloc", "get_allocs"}, time.Now())
|
||||
|
||||
allocs := make([]*structs.Allocation, len(args.AllocIDs))
|
||||
|
||||
// Setup the blocking query. We wait for at least one of the requested
|
||||
// allocations to be above the min query index. This guarantees that the
|
||||
// server has received that index.
|
||||
opts := blockingOptions{
|
||||
queryOpts: &args.QueryOptions,
|
||||
queryMeta: &reply.QueryMeta,
|
||||
run: func(ws memdb.WatchSet, state *state.StateStore) error {
|
||||
// Lookup the allocation
|
||||
thresholdMet := false
|
||||
maxIndex := uint64(0)
|
||||
for i, alloc := range args.AllocIDs {
|
||||
out, err := state.AllocByID(ws, alloc)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if out == nil {
|
||||
// The alloc may have been GC'd or purged. Continue so we can
|
||||
// potentially satisfy the query with partial results from the
|
||||
// remaining allocs.
|
||||
continue
|
||||
}
|
||||
|
||||
// It shouldn't be possible to have an alloc with a nil job, but
|
||||
// let's not block the node from getting other allocs if that's
|
||||
// the case.
|
||||
if out.Job == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if err := a.srv.AuthorizeClientAllocation(aclObj, out, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Store the pointer
|
||||
allocs[i] = out
|
||||
|
||||
// Check if we have passed the minimum index
|
||||
if out.ModifyIndex > args.QueryOptions.MinQueryIndex {
|
||||
thresholdMet = true
|
||||
}
|
||||
|
||||
if maxIndex < out.ModifyIndex {
|
||||
maxIndex = out.ModifyIndex
|
||||
}
|
||||
}
|
||||
|
||||
// Setup the output
|
||||
if thresholdMet {
|
||||
reply.Allocs = allocs
|
||||
reply.Index = maxIndex
|
||||
} else {
|
||||
// Use the last index that affected the allocs table
|
||||
index, err := state.Index("allocs")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reply.Index = index
|
||||
}
|
||||
|
||||
// Set the query response
|
||||
a.srv.setQueryMeta(&reply.QueryMeta)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
return a.srv.blockingRPC(&opts)
|
||||
}
|
||||
|
||||
// Stop is used to stop an allocation and migrate it to another node.
|
||||
func (a *Alloc) Stop(args *structs.AllocStopRequest, reply *structs.AllocStopResponse) error {
|
||||
|
||||
authErr := a.srv.Authenticate(a.ctx, args)
|
||||
if done, err := a.srv.forward("Alloc.Stop", args, args, reply); done {
|
||||
return err
|
||||
}
|
||||
a.srv.MeasureRPCRate("alloc", structs.RateMetricWrite, args)
|
||||
if authErr != nil {
|
||||
return structs.ErrPermissionDenied
|
||||
}
|
||||
|
||||
defer metrics.MeasureSince([]string{"nomad", "alloc", "stop"}, time.Now())
|
||||
|
||||
alloc, err := getAlloc(a.srv.State(), args.AllocID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check for namespace alloc-lifecycle permissions.
|
||||
allowNsOp := acl.NamespaceValidator(acl.NamespaceCapabilityAllocLifecycle)
|
||||
aclObj, err := a.srv.ResolveACL(args)
|
||||
if err != nil {
|
||||
return err
|
||||
} else if !allowNsOp(aclObj, alloc.Namespace) {
|
||||
return structs.ErrPermissionDenied
|
||||
}
|
||||
|
||||
now := time.Now().UTC().UnixNano()
|
||||
eval := &structs.Evaluation{
|
||||
ID: uuid.Generate(),
|
||||
Namespace: alloc.Namespace,
|
||||
Priority: alloc.Job.Priority,
|
||||
Type: alloc.Job.Type,
|
||||
TriggeredBy: structs.EvalTriggerAllocStop,
|
||||
JobID: alloc.Job.ID,
|
||||
JobModifyIndex: alloc.Job.ModifyIndex,
|
||||
Status: structs.EvalStatusPending,
|
||||
CreateTime: now,
|
||||
ModifyTime: now,
|
||||
}
|
||||
|
||||
transitionReq := &structs.AllocUpdateDesiredTransitionRequest{
|
||||
Evals: []*structs.Evaluation{eval},
|
||||
Allocs: map[string]*structs.DesiredTransition{
|
||||
args.AllocID: {
|
||||
Migrate: pointer.Of(true),
|
||||
NoShutdownDelay: pointer.Of(args.NoShutdownDelay),
|
||||
Reschedule: pointer.Of(args.Reschedule),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Commit this update via Raft
|
||||
_, index, err := a.srv.raftApply(structs.AllocUpdateDesiredTransitionRequestType, transitionReq)
|
||||
if err != nil {
|
||||
a.logger.Error("AllocUpdateDesiredTransitionRequest failed", "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// Setup the response
|
||||
reply.Index = index
|
||||
reply.EvalID = eval.ID
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateDesiredTransition is used to update the desired transitions of an
|
||||
// allocation.
|
||||
func (a *Alloc) UpdateDesiredTransition(args *structs.AllocUpdateDesiredTransitionRequest, reply *structs.GenericResponse) error {
|
||||
authErr := a.srv.Authenticate(a.ctx, args)
|
||||
if done, err := a.srv.forward("Alloc.UpdateDesiredTransition", args, args, reply); done {
|
||||
return err
|
||||
}
|
||||
a.srv.MeasureRPCRate("alloc", structs.RateMetricWrite, args)
|
||||
if authErr != nil {
|
||||
return structs.ErrPermissionDenied
|
||||
}
|
||||
|
||||
defer metrics.MeasureSince([]string{"nomad", "alloc", "update_desired_transition"}, time.Now())
|
||||
|
||||
// Check that it is a management token.
|
||||
if aclObj, err := a.srv.ResolveACL(args); err != nil {
|
||||
return err
|
||||
} else if !aclObj.IsManagement() {
|
||||
return structs.ErrPermissionDenied
|
||||
}
|
||||
|
||||
// Ensure at least a single alloc
|
||||
if len(args.Allocs) == 0 {
|
||||
return fmt.Errorf("must update at least one allocation")
|
||||
}
|
||||
|
||||
// Commit this update via Raft
|
||||
_, index, err := a.srv.raftApply(structs.AllocUpdateDesiredTransitionRequestType, args)
|
||||
if err != nil {
|
||||
a.logger.Error("AllocUpdateDesiredTransitionRequest failed", "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// Setup the response
|
||||
reply.Index = index
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetServiceRegistrations returns a list of service registrations which belong
|
||||
// to the passed allocation ID.
|
||||
func (a *Alloc) GetServiceRegistrations(
|
||||
args *structs.AllocServiceRegistrationsRequest,
|
||||
reply *structs.AllocServiceRegistrationsResponse) error {
|
||||
|
||||
authErr := a.srv.Authenticate(a.ctx, args)
|
||||
if done, err := a.srv.forward(structs.AllocServiceRegistrationsRPCMethod, args, args, reply); done {
|
||||
return err
|
||||
}
|
||||
a.srv.MeasureRPCRate("alloc", structs.RateMetricList, args)
|
||||
if authErr != nil {
|
||||
return structs.ErrPermissionDenied
|
||||
}
|
||||
|
||||
defer metrics.MeasureSince([]string{"nomad", "alloc", "get_service_registrations"}, time.Now())
|
||||
|
||||
// Ensure the caller has the read-job namespace capability.
|
||||
aclObj, err := a.srv.ResolveACL(args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !aclObj.AllowNsOp(args.RequestNamespace(), acl.NamespaceCapabilityReadJob) {
|
||||
return structs.ErrPermissionDenied
|
||||
}
|
||||
|
||||
// Set up the blocking query.
|
||||
return a.srv.blockingRPC(&blockingOptions{
|
||||
queryOpts: &args.QueryOptions,
|
||||
queryMeta: &reply.QueryMeta,
|
||||
run: func(ws memdb.WatchSet, stateStore *state.StateStore) error {
|
||||
|
||||
// Read the allocation to ensure its namespace matches the request
|
||||
// args.
|
||||
alloc, err := stateStore.AllocByID(ws, args.AllocID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Guard against the alloc not-existing or that the namespace does
|
||||
// not match the request arguments.
|
||||
if alloc == nil || alloc.Namespace != args.RequestNamespace() {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Perform the state query to get an iterator.
|
||||
iter, err := stateStore.GetServiceRegistrationsByAllocID(ws, args.AllocID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Set up our output after we have checked the error.
|
||||
services := make([]*structs.ServiceRegistration, 0)
|
||||
|
||||
// Iterate the iterator, appending all service registrations
|
||||
// returned to the reply.
|
||||
for raw := iter.Next(); raw != nil; raw = iter.Next() {
|
||||
services = append(services, raw.(*structs.ServiceRegistration))
|
||||
}
|
||||
reply.Services = services
|
||||
|
||||
// Use the index table to populate the query meta as we have no way
|
||||
// of tracking the max index on deletes.
|
||||
return a.srv.setReplyQueryMeta(stateStore, state.TableServiceRegistrations, &reply.QueryMeta)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// SignIdentities allows nodes to retrieve workload identities for their
|
||||
// allocations.
|
||||
//
|
||||
// This is an internal-only RPC and not exposed via the HTTP API.
|
||||
func (a *Alloc) SignIdentities(args *structs.AllocIdentitiesRequest, reply *structs.AllocIdentitiesResponse) error {
|
||||
|
||||
aclObj, err := a.srv.AuthenticateClientOnly(a.ctx, args)
|
||||
if done, err := a.srv.forward("Alloc.SignIdentities", args, args, reply); done {
|
||||
return err
|
||||
}
|
||||
a.srv.MeasureRPCRate("alloc", structs.RateMetricRead, args)
|
||||
if err != nil {
|
||||
return structs.ErrPermissionDenied
|
||||
}
|
||||
defer metrics.MeasureSince([]string{"nomad", "alloc", "sign_identities"}, time.Now())
|
||||
|
||||
if len(args.Identities) == 0 {
|
||||
// Client bug. Fail loudly instead of letting clients waste time with
|
||||
// noops.
|
||||
return fmt.Errorf("no identities requested")
|
||||
}
|
||||
|
||||
// Tracks whether the min index was satisfied by the blocking query
|
||||
thresholdMet := false
|
||||
|
||||
// Most if not all identity requests will be for the same alloc, so create a
|
||||
// set of alloc IDs to avoid unnecessary looping in the blocking query.
|
||||
allocs := make(map[string]*structs.Allocation, len(args.Identities))
|
||||
for _, idReq := range args.Identities {
|
||||
allocs[idReq.AllocID] = nil // to be set while watching
|
||||
}
|
||||
|
||||
opts := blockingOptions{
|
||||
queryOpts: &args.QueryOptions,
|
||||
queryMeta: &reply.QueryMeta,
|
||||
run: func(ws memdb.WatchSet, state *state.StateStore) error {
|
||||
var maxIndex uint64
|
||||
|
||||
// Lookup the allocations
|
||||
for allocID := range allocs {
|
||||
out, err := state.AllocByID(ws, allocID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if out == nil {
|
||||
// Alloc may have been GC'd and therefore should not be able to get
|
||||
// identities signed.
|
||||
continue
|
||||
}
|
||||
|
||||
if err := a.srv.AuthorizeClientAllocation(aclObj, out, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Keep the alloc around so we can skip the statestore lookup later
|
||||
allocs[allocID] = out
|
||||
|
||||
// If we have found a requested alloc created after the min query index
|
||||
// that means we're observing a new enough version of state to satisfy
|
||||
// the query.
|
||||
if out.CreateIndex > args.QueryOptions.MinQueryIndex {
|
||||
thresholdMet = true
|
||||
}
|
||||
|
||||
if maxIndex < out.CreateIndex {
|
||||
maxIndex = out.CreateIndex
|
||||
}
|
||||
}
|
||||
|
||||
// If we could not find an alloc created after the min index, note when
|
||||
// the index allocs were last updated.
|
||||
if !thresholdMet {
|
||||
index, err := state.Index("allocs")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
maxIndex = index
|
||||
}
|
||||
|
||||
reply.Index = maxIndex
|
||||
return nil
|
||||
}}
|
||||
|
||||
if err := a.srv.blockingRPC(&opts); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Index threshold was not met in the blocking query. Set rejections since
|
||||
// allocs could not be found and should be considered invalid.
|
||||
if !thresholdMet {
|
||||
for _, idReq := range args.Identities {
|
||||
reply.Rejections = append(reply.Rejections, &structs.WorkloadIdentityRejection{
|
||||
WorkloadIdentityRequest: *idReq,
|
||||
Reason: structs.WIRejectionReasonMissingAlloc,
|
||||
})
|
||||
}
|
||||
|
||||
// Return early so the rest of the func acts as the else (thresholdMet)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Threshold met, so create the response
|
||||
now := time.Now().UTC()
|
||||
for _, idReq := range args.Identities {
|
||||
out := allocs[idReq.AllocID]
|
||||
|
||||
if out == nil {
|
||||
// Alloc may have been GC'd and therefore should not be able to get
|
||||
// new identities.
|
||||
reply.Rejections = append(reply.Rejections, &structs.WorkloadIdentityRejection{
|
||||
WorkloadIdentityRequest: *idReq,
|
||||
Reason: structs.WIRejectionReasonMissingAlloc,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
job := out.Job
|
||||
|
||||
switch idReq.WorkloadType {
|
||||
case structs.WorkloadTypeTask:
|
||||
task := out.LookupTask(idReq.WorkloadIdentifier)
|
||||
if task == nil {
|
||||
// Job has likely been updated to remove this task
|
||||
reply.Rejections = append(reply.Rejections, &structs.WorkloadIdentityRejection{
|
||||
WorkloadIdentityRequest: *idReq,
|
||||
Reason: structs.WIRejectionReasonMissingTask,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
widFound, err := a.signTasks(task, out, idReq, reply, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !widFound {
|
||||
reply.Rejections = append(reply.Rejections, &structs.WorkloadIdentityRejection{
|
||||
WorkloadIdentityRequest: *idReq,
|
||||
Reason: structs.WIRejectionReasonMissingIdentity,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
case structs.WorkloadTypeService:
|
||||
widFound, err := a.signServices(job, out, idReq, reply, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !widFound {
|
||||
reply.Rejections = append(reply.Rejections, &structs.WorkloadIdentityRejection{
|
||||
WorkloadIdentityRequest: *idReq,
|
||||
Reason: structs.WIRejectionReasonMissingIdentity,
|
||||
})
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *Alloc) signTasks(
|
||||
task *structs.Task,
|
||||
alloc *structs.Allocation,
|
||||
idReq *structs.WorkloadIdentityRequest,
|
||||
reply *structs.AllocIdentitiesResponse,
|
||||
now time.Time,
|
||||
) (widFound bool, err error) {
|
||||
for _, wid := range task.Identities {
|
||||
if wid.Name != idReq.IdentityName {
|
||||
continue
|
||||
}
|
||||
|
||||
widFound = true
|
||||
builder := structs.NewIdentityClaimsBuilder(alloc.Job, alloc, &idReq.WIHandle, wid).
|
||||
WithTask(task).
|
||||
WithConsul()
|
||||
|
||||
var node *structs.Node
|
||||
node, err = a.srv.State().NodeByID(nil, alloc.NodeID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
builder.WithNode(node)
|
||||
|
||||
vaultCfg := a.srv.GetConfig().GetVaultForIdentity(wid)
|
||||
if vaultCfg != nil && vaultCfg.DefaultIdentity != nil {
|
||||
builder.WithVault(vaultCfg.DefaultIdentity.ExtraClaims)
|
||||
}
|
||||
|
||||
claims := builder.Build(now)
|
||||
err = a.signClaims(claims, idReq, reply)
|
||||
break
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (a *Alloc) signServices(
|
||||
job *structs.Job,
|
||||
alloc *structs.Allocation,
|
||||
idReq *structs.WorkloadIdentityRequest,
|
||||
reply *structs.AllocIdentitiesResponse,
|
||||
now time.Time,
|
||||
) (widFound bool, err error) {
|
||||
wid := idReq.WIHandle
|
||||
|
||||
// services can be on the level of task groups or tasks
|
||||
for _, tg := range job.TaskGroups {
|
||||
for _, service := range tg.Services {
|
||||
if service.IdentityHandle(nil).Equal(wid) {
|
||||
claims := structs.NewIdentityClaimsBuilder(
|
||||
alloc.Job, alloc, &idReq.WIHandle, service.Identity).
|
||||
WithConsul().
|
||||
WithService(service).
|
||||
Build(now)
|
||||
return true, a.signClaims(claims, idReq, reply)
|
||||
}
|
||||
}
|
||||
for _, task := range tg.Tasks {
|
||||
for _, service := range task.Services {
|
||||
if service.IdentityHandle(nil).Equal(wid) {
|
||||
claims := structs.NewIdentityClaimsBuilder(
|
||||
alloc.Job, alloc, &idReq.WIHandle, service.Identity).
|
||||
WithTask(task).
|
||||
WithConsul().
|
||||
WithService(service).
|
||||
Build(now)
|
||||
return true, a.signClaims(claims, idReq, reply)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (a *Alloc) signClaims(
|
||||
claims *structs.IdentityClaims,
|
||||
idReq *structs.WorkloadIdentityRequest,
|
||||
reply *structs.AllocIdentitiesResponse,
|
||||
) error {
|
||||
token, _, err := a.srv.encrypter.SignClaims(claims)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reply.SignedIdentities = append(reply.SignedIdentities, &structs.SignedWorkloadIdentity{
|
||||
WorkloadIdentityRequest: *idReq,
|
||||
JWT: token,
|
||||
Expiration: claims.Expiry.Time(),
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,957 +0,0 @@
|
||||
// Copyright IBM Corp. 2015, 2025
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/x509"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/hashicorp/go-hclog"
|
||||
metrics "github.com/hashicorp/go-metrics/compat"
|
||||
"github.com/hashicorp/nomad/acl"
|
||||
"github.com/hashicorp/nomad/helper"
|
||||
"github.com/hashicorp/nomad/nomad/state"
|
||||
"github.com/hashicorp/nomad/nomad/structs"
|
||||
)
|
||||
|
||||
// aclCacheSize is the number of ACL objects to keep cached. ACLs have a parsing
|
||||
// and construction cost, so we keep the hot objects cached to reduce the ACL
|
||||
// token resolution time.
|
||||
const aclCacheSize = 512
|
||||
|
||||
type StateGetter func() *state.StateStore
|
||||
type LeaderACLGetter func() string
|
||||
|
||||
type RPCContext interface {
|
||||
IsTLS() bool
|
||||
IsStatic() bool
|
||||
Certificate() *x509.Certificate
|
||||
GetRemoteIP() (net.IP, error)
|
||||
}
|
||||
|
||||
type Encrypter interface {
|
||||
VerifyClaim(string) (*structs.IdentityClaims, error)
|
||||
}
|
||||
|
||||
type Authenticator struct {
|
||||
aclsEnabled bool
|
||||
|
||||
// verifyTLS is used to determine whether the server should verify TLS and
|
||||
// is an atomic bool, so that the server TLS reload can update it at runtime
|
||||
// with a race condition.
|
||||
verifyTLS *atomic.Bool
|
||||
|
||||
logger hclog.Logger
|
||||
getState StateGetter
|
||||
getLeaderACL LeaderACLGetter
|
||||
region string
|
||||
|
||||
validServerCertNames []string
|
||||
validClientCertNames []string
|
||||
|
||||
// aclCache is used to maintain the parsed ACL objects
|
||||
aclCache *structs.ACLCache[*acl.ACL]
|
||||
|
||||
// encrypter is a pointer to the server's Encrypter that can be used to
|
||||
// verify claims
|
||||
encrypter Encrypter
|
||||
}
|
||||
|
||||
type AuthenticatorConfig struct {
|
||||
StateFn StateGetter
|
||||
Logger hclog.Logger
|
||||
GetLeaderACLFn LeaderACLGetter
|
||||
AclsEnabled bool
|
||||
VerifyTLS bool
|
||||
Region string
|
||||
Encrypter Encrypter
|
||||
}
|
||||
|
||||
func NewAuthenticator(cfg *AuthenticatorConfig) *Authenticator {
|
||||
a := Authenticator{
|
||||
aclsEnabled: cfg.AclsEnabled,
|
||||
verifyTLS: &atomic.Bool{},
|
||||
logger: cfg.Logger.With("auth"),
|
||||
getState: cfg.StateFn,
|
||||
getLeaderACL: cfg.GetLeaderACLFn,
|
||||
region: cfg.Region,
|
||||
aclCache: structs.NewACLCache[*acl.ACL](aclCacheSize),
|
||||
encrypter: cfg.Encrypter,
|
||||
validServerCertNames: []string{"server." + cfg.Region + ".nomad"},
|
||||
validClientCertNames: []string{
|
||||
"client." + cfg.Region + ".nomad",
|
||||
"server." + cfg.Region + ".nomad",
|
||||
},
|
||||
}
|
||||
|
||||
a.verifyTLS.Store(cfg.VerifyTLS)
|
||||
return &a
|
||||
}
|
||||
|
||||
// SetVerifyTLS is a helper method to set the verifyTLS field. This is used in
|
||||
// when the server TLS configuration is updated.
|
||||
func (s *Authenticator) SetVerifyTLS(verifyTLS bool) { s.verifyTLS.Store(verifyTLS) }
|
||||
|
||||
// Authenticate extracts an AuthenticatedIdentity from the request context or
|
||||
// provided token and sets the identity on the request. The caller can extract
|
||||
// an acl.ACL, WorkloadIdentity, or other identifying tokens to use for
|
||||
// authorization. Keeping these fields independent rather than merging them into
|
||||
// an ephemeral ACLToken makes the original of the credential clear to RPC
|
||||
// handlers, who may have different behavior for internal vs external origins.
|
||||
//
|
||||
// Note: when making a server-to-server RPC that authenticates with this method,
|
||||
// the RPC *must* include the leader's ACL token. Use AuthenticateServerOnly for
|
||||
// requests that don't have access to the leader's ACL token.
|
||||
//
|
||||
// Note: when called on the follower we'll be making stale queries, so it's
|
||||
// possible if the follower is behind that the leader will get a different value
|
||||
// if an ACL token or allocation's WI has just been created.
|
||||
//
|
||||
// This method returns errors that are used for testing diagnostics. RPC callers
|
||||
// should always return ErrPermissionDenied after checking forwarding when one
|
||||
// of these errors is received.
|
||||
func (s *Authenticator) Authenticate(ctx RPCContext, args structs.RequestWithIdentity) error {
|
||||
|
||||
// get the user ACLToken or anonymous token
|
||||
secretID := args.GetAuthToken()
|
||||
aclToken, err := s.resolveSecretToken(secretID)
|
||||
|
||||
switch {
|
||||
case err == nil && (aclToken == structs.AnonymousACLToken ||
|
||||
aclToken == structs.ACLsDisabledToken):
|
||||
// When ACLs are disabled or if we have an anonymous token, we want to
|
||||
// continue on to check mTLS certs, if available, so set the token but
|
||||
// don't return yet
|
||||
args.SetIdentity(&structs.AuthenticatedIdentity{ACLToken: aclToken})
|
||||
|
||||
case err == nil:
|
||||
// ACLs are enabled and we have a non-anonymous token, so set that as
|
||||
// our identity and return
|
||||
args.SetIdentity(&structs.AuthenticatedIdentity{ACLToken: aclToken})
|
||||
return nil
|
||||
|
||||
case errors.Is(err, structs.ErrTokenExpired):
|
||||
return err
|
||||
|
||||
case errors.Is(err, structs.ErrTokenInvalid):
|
||||
// if it's not a UUID it might be an identity claim
|
||||
claims, err := s.VerifyClaim(secretID)
|
||||
if err != nil {
|
||||
// we already know the token wasn't valid for an ACL in the state
|
||||
// store, so if we get an error at this point we have an invalid
|
||||
// token and there are no other options but to bail out
|
||||
return err
|
||||
}
|
||||
|
||||
args.SetIdentity(&structs.AuthenticatedIdentity{Claims: claims})
|
||||
return nil
|
||||
|
||||
case errors.Is(err, structs.ErrTokenNotFound):
|
||||
// Check if the secret ID is the leader's secret ID, in which case treat
|
||||
// it as a management token.
|
||||
leaderAcl := s.getLeaderACL()
|
||||
if leaderAcl != "" && secretID == leaderAcl {
|
||||
aclToken = structs.LeaderACLToken
|
||||
break
|
||||
} else {
|
||||
// Otherwise, see if the secret ID belongs to a node. We should
|
||||
// reach this point only on first connection.
|
||||
node, err := s.getState().NodeBySecretID(nil, secretID)
|
||||
if err != nil {
|
||||
// this is a go-memdb error; shouldn't happen
|
||||
return fmt.Errorf("could not resolve node secret: %w", err)
|
||||
}
|
||||
if node != nil {
|
||||
args.SetIdentity(&structs.AuthenticatedIdentity{ClientID: node.ID})
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// we were passed a bogus token so we'll return an error, but we'll also
|
||||
// want to capture the IP for metrics
|
||||
remoteIP, err := ctx.GetRemoteIP()
|
||||
if err != nil {
|
||||
s.logger.Error("could not determine remote address", "error", err)
|
||||
}
|
||||
args.SetIdentity(&structs.AuthenticatedIdentity{RemoteIP: remoteIP})
|
||||
return structs.ErrPermissionDenied
|
||||
|
||||
default: // any other error
|
||||
return fmt.Errorf("could not resolve user: %w", err)
|
||||
|
||||
}
|
||||
|
||||
// If there's no context we're in a "static" handler which only happens for
|
||||
// cases where the leader is making RPCs internally (volumewatcher and
|
||||
// deploymentwatcher)
|
||||
if ctx.IsStatic() {
|
||||
args.SetIdentity(&structs.AuthenticatedIdentity{ACLToken: aclToken})
|
||||
return nil
|
||||
}
|
||||
|
||||
// At this point we either have an anonymous token or an invalid one.
|
||||
|
||||
// Unlike clients that provide their Node ID on first connection, server
|
||||
// RPCs don't include an ID for the server so we identify servers by cert
|
||||
// and IP address.
|
||||
identity := &structs.AuthenticatedIdentity{ACLToken: aclToken}
|
||||
if ctx.IsTLS() {
|
||||
identity.TLSName = ctx.Certificate().Subject.CommonName
|
||||
}
|
||||
|
||||
remoteIP, err := ctx.GetRemoteIP()
|
||||
if err != nil {
|
||||
s.logger.Error(
|
||||
"could not authenticate RPC request or determine remote address", "error", err)
|
||||
return err
|
||||
}
|
||||
identity.RemoteIP = remoteIP
|
||||
args.SetIdentity(identity)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ResolveACL is an authentication wrapper that handles resolving ACL
|
||||
// tokens, Workload Identities, or client secrets into acl.ACL objects.
|
||||
// Exclusively server-to-server or client-to-server requests should be using
|
||||
// AuthenticateServerOnly or AuthenticateClientOnly unless they use the
|
||||
// AuthenticateNodeIdentityGenerator function.
|
||||
func (s *Authenticator) ResolveACL(args structs.RequestWithIdentity) (*acl.ACL, error) {
|
||||
identity := args.GetIdentity()
|
||||
if identity == nil {
|
||||
// should never happen
|
||||
return nil, structs.ErrPermissionDenied
|
||||
}
|
||||
|
||||
if !s.aclsEnabled {
|
||||
return acl.ACLsDisabledACL, nil
|
||||
}
|
||||
|
||||
if aclObj, err := s.ResolveClientIdentityACL(identity); aclObj != nil || err != nil {
|
||||
return aclObj, err
|
||||
}
|
||||
|
||||
claims := identity.GetClaims()
|
||||
if claims != nil {
|
||||
return s.resolveClaims(claims)
|
||||
}
|
||||
|
||||
// this will include any anonymous token, so this is the last chance to
|
||||
// avoid an error
|
||||
aclToken := identity.GetACLToken()
|
||||
if aclToken != nil {
|
||||
return s.resolveACLForToken(aclToken)
|
||||
}
|
||||
|
||||
return nil, structs.ErrPermissionDenied
|
||||
}
|
||||
|
||||
// AuthenticateServerOnly returns an ACL object for use *only* with internal
|
||||
// server-to-server RPCs. This should never be used for RPCs that serve HTTP
|
||||
// endpoints or accept ACL tokens to avoid confused deputy attacks by making a
|
||||
// request to a follower that's forwarded.
|
||||
//
|
||||
// The returned ACL object is always an acl.ServerACL but in the future this
|
||||
// could be extended to allow servers to have jurisdiction over specific pools,
|
||||
// etc.
|
||||
func (s *Authenticator) AuthenticateServerOnly(ctx RPCContext, args structs.RequestWithIdentity) (*acl.ACL, error) {
|
||||
|
||||
remoteIP, err := ctx.GetRemoteIP() // capture for metrics
|
||||
if err != nil {
|
||||
s.logger.Error("could not determine remote address", "error", err)
|
||||
}
|
||||
|
||||
identity := &structs.AuthenticatedIdentity{RemoteIP: remoteIP}
|
||||
defer args.SetIdentity(identity) // always set the identity, even on errors
|
||||
|
||||
// Note: if servers had auth tokens like clients do, we would be able to
|
||||
// verify them here and only return the server ACL for actual servers even
|
||||
// if mTLS was disabled. Without mTLS, any request can spoof server RPCs.
|
||||
// This is known and documented in the Security Model:
|
||||
// https://developer.hashicorp.com/nomad/docs/secure/acl
|
||||
if err := verifyTLS(s.verifyTLS.Load(), ctx, s.validServerCertNames, identity); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return acl.ServerACL, nil
|
||||
}
|
||||
|
||||
// AuthenticateNodeIdentityGenerator is used for RPC endpoints (Node.Register
|
||||
// and Node.UpdateStatus) that have the potential to generate node identities.
|
||||
//
|
||||
// While the Authenticate method serves as a complete general purpose
|
||||
// authenticator, in some critical cases for identity generation checking, it
|
||||
// swallows the information needed.
|
||||
func (s *Authenticator) AuthenticateNodeIdentityGenerator(ctx RPCContext, args structs.RequestWithIdentity) error {
|
||||
|
||||
remoteIP, err := ctx.GetRemoteIP() // capture for metrics
|
||||
if err != nil {
|
||||
s.logger.Error("could not determine remote address", "error", err)
|
||||
}
|
||||
|
||||
identity := &structs.AuthenticatedIdentity{RemoteIP: remoteIP}
|
||||
defer args.SetIdentity(identity)
|
||||
|
||||
if err := verifyTLS(s.verifyTLS.Load(), ctx, s.validClientCertNames, identity); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
authToken := args.GetAuthToken()
|
||||
|
||||
// If the auth token is empty, we treat it as an anonymous request. In the
|
||||
// event of a node registration, this means the node is not yet registered.
|
||||
if authToken == "" {
|
||||
identity.ACLToken = structs.AnonymousACLToken
|
||||
return nil
|
||||
}
|
||||
|
||||
// If the auth token is a UUID, we check whether it's a node secret ID or
|
||||
// the leader's ACL token. If it's not a UUID, we assume it's a node
|
||||
// identity. Anything outside these cases is not supported and no identity
|
||||
// will be set.
|
||||
if helper.IsUUID(authToken) {
|
||||
if leaderACL := s.getLeaderACL(); leaderACL != "" && authToken == leaderACL {
|
||||
identity.ACLToken = structs.LeaderACLToken
|
||||
} else {
|
||||
node, err := s.getState().NodeBySecretID(nil, authToken)
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not resolve node secret: %w", err)
|
||||
}
|
||||
if node == nil {
|
||||
return structs.ErrPermissionDenied
|
||||
}
|
||||
identity.ClientID = node.ID
|
||||
}
|
||||
} else {
|
||||
// When verifying a node identity claim, we do not want to swallow the
|
||||
// initial error. This is because the caller may want to handle the
|
||||
// error type in the case that the JWT is expired.
|
||||
claims, err := s.VerifyClaim(authToken)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !claims.IsNode() && !claims.IsNodeIntroduction() {
|
||||
return structs.ErrPermissionDenied
|
||||
}
|
||||
identity.Claims = claims
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AuthenticateClientOnly returns an ACL object for use *only* with internal
|
||||
// RPCs originating from clients (including those forwarded). This should never
|
||||
// be used for RPCs that serve HTTP endpoints to avoid confused deputy attacks
|
||||
// by making a request to a client that's forwarded. It should also not be used
|
||||
// with Node.Register or NodeUpdateStatus, which should use
|
||||
// AuthenticateNodeIdentityGenerator.
|
||||
func (s *Authenticator) AuthenticateClientOnly(ctx RPCContext, args structs.RequestWithIdentity) (*acl.ACL, error) {
|
||||
|
||||
remoteIP, err := ctx.GetRemoteIP() // capture for metrics
|
||||
if err != nil {
|
||||
s.logger.Error("could not determine remote address", "error", err)
|
||||
}
|
||||
|
||||
identity := &structs.AuthenticatedIdentity{RemoteIP: remoteIP}
|
||||
defer args.SetIdentity(identity) // always set the identity, even on errors
|
||||
|
||||
if err := verifyTLS(s.verifyTLS.Load(), ctx, s.validClientCertNames, identity); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
authToken := args.GetAuthToken()
|
||||
if authToken == "" {
|
||||
return nil, structs.ErrPermissionDenied
|
||||
}
|
||||
|
||||
// If the auth token is a UUID, we treat it as a node secret ID. Otherwise,
|
||||
// we assume it's a node identity claim. Anything outside these cases is not
|
||||
// permitted when using this method.
|
||||
if helper.IsUUID(authToken) {
|
||||
node, err := s.getState().NodeBySecretID(nil, authToken)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not resolve node secret: %w", err)
|
||||
}
|
||||
if node == nil {
|
||||
return nil, structs.ErrPermissionDenied
|
||||
}
|
||||
identity.ClientID = node.ID
|
||||
} else {
|
||||
claims, err := s.VerifyClaim(authToken)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !claims.IsNode() || claims.NodeIdentityClaims == nil {
|
||||
return nil, structs.ErrPermissionDenied
|
||||
}
|
||||
identity.ClientID = claims.NodeIdentityClaims.NodeID
|
||||
identity.Claims = claims
|
||||
}
|
||||
|
||||
return s.ResolveClientIdentityACL(identity)
|
||||
}
|
||||
|
||||
// verifyTLS is a helper function that performs TLS verification, if required,
|
||||
// given the passed RPCContext and valid names.
|
||||
//
|
||||
// It will always set the TLSName on the identity if we are performing
|
||||
// verification, so callers don't have to worry about setting it themselves.
|
||||
func verifyTLS(verify bool, ctx RPCContext, validNames []string, identity *structs.AuthenticatedIdentity) error {
|
||||
|
||||
if verify && !ctx.IsStatic() {
|
||||
|
||||
tlsCert := ctx.Certificate()
|
||||
if tlsCert == nil {
|
||||
return errors.New("missing certificate information")
|
||||
}
|
||||
|
||||
// Always set on the identity, even before validating the name, so we
|
||||
// can capture it for metrics.
|
||||
identity.TLSName = tlsCert.Subject.CommonName
|
||||
|
||||
// Perform the certificate validation, using the passed valid names.
|
||||
_, err := validateCertificateForNames(tlsCert, validNames)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateCertificateForNames returns true if the certificate is valid for any
|
||||
// of the given domain names.
|
||||
func validateCertificateForNames(cert *x509.Certificate, expectedNames []string) (bool, error) {
|
||||
if cert == nil {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
validNames := []string{cert.Subject.CommonName}
|
||||
validNames = append(validNames, cert.DNSNames...)
|
||||
|
||||
for _, expectedName := range expectedNames {
|
||||
if slices.Contains(validNames, expectedName) {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
|
||||
return false, fmt.Errorf("invalid certificate: %s not in expected %s",
|
||||
strings.Join(validNames, ", "),
|
||||
strings.Join(expectedNames, ", "))
|
||||
|
||||
}
|
||||
|
||||
// IdentityToACLClaim returns an ACLClaim suitable for checking permissions
|
||||
func IdentityToACLClaim(ai *structs.AuthenticatedIdentity, store *state.StateStore) *acl.ACLClaim {
|
||||
if ai == nil || ai.Claims == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var group string
|
||||
alloc, err := store.AllocByID(nil, ai.Claims.AllocationID)
|
||||
if err != nil {
|
||||
// we should never hit this error, but if we did the caller would get a
|
||||
// nil claim and auth will fail
|
||||
return nil
|
||||
}
|
||||
if alloc != nil {
|
||||
group = alloc.TaskGroup
|
||||
}
|
||||
|
||||
return &acl.ACLClaim{
|
||||
Namespace: ai.Claims.Namespace,
|
||||
Job: ai.Claims.JobID,
|
||||
Group: group,
|
||||
Task: ai.Claims.TaskName,
|
||||
}
|
||||
}
|
||||
|
||||
// resolveACLForToken resolves an ACL from a token only. It should be used only
|
||||
// by Variables endpoints, which have additional implicit policies for their
|
||||
// claims so we can't wrap them up in ResolveACL.
|
||||
func (s *Authenticator) resolveACLForToken(aclToken *structs.ACLToken) (*acl.ACL, error) {
|
||||
snap, err := s.getState().Snapshot()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resolveACLFromToken(snap, s.aclCache, aclToken)
|
||||
}
|
||||
|
||||
// ResolveToken is used to translate an ACL Token Secret ID into
|
||||
// an ACL object, nil if ACLs are disabled, or an error.
|
||||
//
|
||||
// TODO(tgross): this is used in lots of places we probably should be calling
|
||||
// Authenticate + ResolveACL on, because they support HTTP APIs that may be used
|
||||
// by the Task API
|
||||
func (s *Authenticator) ResolveToken(secretID string) (*acl.ACL, error) {
|
||||
// Fast-path if ACLs are disabled
|
||||
if !s.aclsEnabled {
|
||||
return acl.ACLsDisabledACL, nil
|
||||
}
|
||||
defer metrics.MeasureSince([]string{"nomad", "acl", "resolveToken"}, time.Now())
|
||||
|
||||
// Check if the secret ID is the leader secret ID, in which case treat it as
|
||||
// a management token.
|
||||
if leaderAcl := s.getLeaderACL(); leaderAcl != "" && secretID == leaderAcl {
|
||||
return acl.ManagementACL, nil
|
||||
}
|
||||
|
||||
// Snapshot the state
|
||||
snap, err := s.getState().Snapshot()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Resolve the ACL
|
||||
return resolveTokenFromSnapshotCache(snap, s.aclCache, secretID)
|
||||
}
|
||||
|
||||
// VerifyClaim asserts that the token is valid. If it is for a workload
|
||||
// identity, it will ensure that the resulting allocation ID belongs to a
|
||||
// non-terminal allocation. If the token is for a node identity, it will ensure
|
||||
// the node ID matches the claim.
|
||||
//
|
||||
// This should usually not be called by RPC handlers.
|
||||
func (s *Authenticator) VerifyClaim(token string) (*structs.IdentityClaims, error) {
|
||||
|
||||
claims, err := s.encrypter.VerifyClaim(token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if claims.IsWorkload() {
|
||||
if err := s.verifyWorkloadIdentityClaim(claims); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
// If the claims are for a node identity, we can return them directly once
|
||||
// we have verified the claim. In the happy path, we could read the node out
|
||||
// of state and verify that it is found. However, it is possible the node
|
||||
// has been garbage collected, and if we failed on that check, the node
|
||||
// would not be able to register again without manual intervention.
|
||||
if claims.IsNode() {
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
// Node introduction claims are a case where we don't verify them against
|
||||
// the state store, since they are used to introduce a node that does not
|
||||
// yet exist.
|
||||
if claims.IsNodeIntroduction() {
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
return nil, errors.New("failed to determine claim type")
|
||||
}
|
||||
|
||||
// ResolveClientIdentityACL resolves a client ACL from an authenticated node
|
||||
// identity or client secret based identity. It returns (nil, nil) if the
|
||||
// identity does not represent a client.
|
||||
func (s *Authenticator) ResolveClientIdentityACL(identity *structs.AuthenticatedIdentity) (*acl.ACL, error) {
|
||||
if identity == nil || identity.ClientID == "" {
|
||||
return nil, nil
|
||||
}
|
||||
if claims := identity.GetClaims(); claims != nil {
|
||||
if !claims.IsNode() || claims.NodeIdentityClaims == nil || claims.NodeIdentityClaims.NodePool == "" {
|
||||
return nil, structs.ErrPermissionDenied
|
||||
}
|
||||
return acl.NewClientACL(claims.NodeIdentityClaims.NodePool), nil
|
||||
}
|
||||
|
||||
snap, err := s.getState().Snapshot()
|
||||
if err != nil {
|
||||
return nil, structs.ErrPermissionDenied
|
||||
}
|
||||
node, err := snap.NodeByID(nil, identity.ClientID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if node == nil {
|
||||
return nil, structs.ErrPermissionDenied
|
||||
}
|
||||
return acl.NewClientACL(node.NodePool), nil
|
||||
}
|
||||
|
||||
// AuthenticatedNodeID returns the authenticated node ID for client and node
|
||||
// identity based requests.
|
||||
func AuthenticatedNodeID(identity *structs.AuthenticatedIdentity) string {
|
||||
if identity == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
if identity.ClientID != "" {
|
||||
return identity.ClientID
|
||||
}
|
||||
|
||||
claims := identity.GetClaims()
|
||||
if claims != nil && claims.IsNode() && claims.NodeIdentityClaims != nil {
|
||||
return claims.NodeIdentityClaims.NodeID
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// AuthorizeSameNode returns ErrPermissionDenied unless the authenticated
|
||||
// identity represents the same node as targetNodeID.
|
||||
func AuthorizeSameNode(identity *structs.AuthenticatedIdentity, targetNodeID string) error {
|
||||
if AuthenticatedNodeID(identity) != targetNodeID {
|
||||
return structs.ErrPermissionDenied
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// AuthorizeSameNodeServiceRegistrations returns ErrPermissionDenied unless the
|
||||
// authenticated identity represents the same node as every service
|
||||
// registration's NodeID.
|
||||
func AuthorizeSameNodeServiceRegistrations(
|
||||
identity *structs.AuthenticatedIdentity,
|
||||
services []*structs.ServiceRegistration,
|
||||
) error {
|
||||
for _, service := range services {
|
||||
if service == nil {
|
||||
return structs.ErrPermissionDenied
|
||||
}
|
||||
if err := AuthorizeSameNode(identity, service.NodeID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ResolveAuthorizedClientNodePoolByNodeID resolves the node pool for nodeID
|
||||
func resolveAuthorizedClientNodePoolByNodeID(snap *state.StateSnapshot, aclObj *acl.ACL, nodeID string) (string, error) {
|
||||
if nodeID == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
pool, ok, err := snap.NodePoolByNodeID(nil, nodeID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !ok {
|
||||
return "", nil
|
||||
}
|
||||
if !aclObj.AllowClientOp(pool) {
|
||||
return "", structs.ErrPermissionDenied
|
||||
}
|
||||
|
||||
return pool, nil
|
||||
}
|
||||
|
||||
// ResolveAuthorizedClientNodePoolByNodeID resolves the node pool for nodeID
|
||||
// and returns it if aclObj is authorized for that pool. It returns an empty
|
||||
// pool and nil error if the node does not exist yet, which allows callers to
|
||||
// continue handling blocking-query flows that may legitimately observe a
|
||||
// missing node.
|
||||
func (s *Authenticator) ResolveAuthorizedClientNodePoolByNodeID(aclObj *acl.ACL, nodeID string) (string, error) {
|
||||
snap, err := s.getState().Snapshot()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return resolveAuthorizedClientNodePoolByNodeID(snap, aclObj, nodeID)
|
||||
}
|
||||
|
||||
// AuthorizeClientAllocation returns ErrPermissionDenied unless aclObj is
|
||||
// authorized for alloc's node pool. If allowNsOp is provided, callers may
|
||||
// fall back to namespace-based authorization when client-scoped authorization
|
||||
// does not apply.
|
||||
func (s *Authenticator) AuthorizeClientAllocation(
|
||||
aclObj *acl.ACL,
|
||||
alloc *structs.Allocation,
|
||||
allowNsOp func(*acl.ACL, string) bool,
|
||||
) error {
|
||||
if alloc == nil || alloc.Job == nil {
|
||||
return structs.ErrPermissionDenied
|
||||
}
|
||||
|
||||
if aclObj.AllowClientOp(alloc.Job.NodePool) {
|
||||
return nil
|
||||
}
|
||||
|
||||
if allowNsOp != nil && allowNsOp(aclObj, alloc.Namespace) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return structs.ErrPermissionDenied
|
||||
}
|
||||
|
||||
// ResolveAuthorizedClientNodePoolByServiceRegistrationID resolves the node pool
|
||||
// for the node backing a service registration and returns it if aclObj is
|
||||
// authorized for that pool.
|
||||
func (s *Authenticator) ResolveAuthorizedClientNodePoolByServiceRegistrationID(
|
||||
aclObj *acl.ACL,
|
||||
namespace, id string,
|
||||
) (string, error) {
|
||||
if aclObj == nil || !(aclObj.AllowAgentRead() || aclObj.AllowServerOp() || aclObj.AllowNodeRead()) {
|
||||
return "", structs.ErrPermissionDenied
|
||||
}
|
||||
|
||||
snap, err := s.getState().Snapshot()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
registration, err := snap.GetServiceRegistrationByID(nil, namespace, id)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if registration == nil {
|
||||
return "", structs.ErrPermissionDenied
|
||||
}
|
||||
|
||||
return resolveAuthorizedClientNodePoolByNodeID(snap, aclObj, registration.NodeID)
|
||||
}
|
||||
|
||||
func (s *Authenticator) verifyWorkloadIdentityClaim(claims *structs.IdentityClaims) error {
|
||||
snap, err := s.getState().Snapshot()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
alloc, err := snap.AllocByID(nil, claims.AllocationID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if alloc == nil || alloc.Job == nil {
|
||||
return fmt.Errorf("allocation does not exist")
|
||||
}
|
||||
|
||||
// the claims for terminal allocs are always treated as expired
|
||||
if alloc.ClientTerminalStatus() {
|
||||
return fmt.Errorf("allocation is terminal")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Authenticator) resolveClaims(claims *structs.IdentityClaims) (*acl.ACL, error) {
|
||||
|
||||
// Nomad node identity claims currently map to a client ACL. If we open this
|
||||
// up in the future, we will want to modify this section to perform similar
|
||||
// work that is done for workload claims.
|
||||
if claims.IsNode() {
|
||||
if claims.NodeIdentityClaims == nil || claims.NodeIdentityClaims.NodePool == "" {
|
||||
return nil, fmt.Errorf("node identity claims missing node pool")
|
||||
}
|
||||
return acl.NewClientACL(claims.NodeIdentityClaims.NodePool), nil
|
||||
}
|
||||
|
||||
policies, err := s.ResolvePoliciesForClaims(claims)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Compile and cache the ACL object. For many claims this will result in an
|
||||
// ACL object with no policies, which can be efficiently cached.
|
||||
aclObj, err := structs.CompileACLObject(s.aclCache, policies)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return aclObj, nil
|
||||
}
|
||||
|
||||
// resolveTokenFromSnapshotCache is used to resolve an ACL object from a
|
||||
// snapshot of state, using a cache to avoid parsing and ACL construction when
|
||||
// possible. It is split from resolveToken to simplify testing.
|
||||
func resolveTokenFromSnapshotCache(snap *state.StateSnapshot, cache *structs.ACLCache[*acl.ACL], secretID string) (*acl.ACL, error) {
|
||||
// Lookup the ACL Token
|
||||
var token *structs.ACLToken
|
||||
var err error
|
||||
|
||||
// Handle anonymous requests
|
||||
if secretID == "" {
|
||||
token = structs.AnonymousACLToken
|
||||
} else {
|
||||
token, err = snap.ACLTokenBySecretID(nil, secretID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if token == nil {
|
||||
return nil, structs.ErrTokenNotFound
|
||||
}
|
||||
if token.IsExpired(time.Now().UTC()) {
|
||||
return nil, structs.ErrTokenExpired
|
||||
}
|
||||
}
|
||||
|
||||
return resolveACLFromToken(snap, cache, token)
|
||||
|
||||
}
|
||||
|
||||
func resolveACLFromToken(snap *state.StateSnapshot, cache *structs.ACLCache[*acl.ACL], token *structs.ACLToken) (*acl.ACL, error) {
|
||||
|
||||
// Check if this is a management token
|
||||
if token.Type == structs.ACLManagementToken {
|
||||
return acl.ManagementACL, nil
|
||||
}
|
||||
|
||||
// Store all policies detailed in the token request, this includes the
|
||||
// named policies and those referenced within the role link.
|
||||
policies := make([]*structs.ACLPolicy, 0, len(token.Policies)+len(token.Roles))
|
||||
|
||||
// Iterate all the token policies and add these to our policy tracking
|
||||
// array.
|
||||
for _, policyName := range token.Policies {
|
||||
policy, err := snap.ACLPolicyByName(nil, policyName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if policy == nil {
|
||||
// Ignore policies that don't exist, since they don't grant any
|
||||
// more privilege.
|
||||
continue
|
||||
}
|
||||
|
||||
// Add the policy to the tracking array.
|
||||
policies = append(policies, policy)
|
||||
}
|
||||
|
||||
// Iterate all the token role links, so we can unpack these and identify
|
||||
// the ACL policies.
|
||||
for _, roleLink := range token.Roles {
|
||||
|
||||
// Any error reading the role means we cannot move forward. We just
|
||||
// ignore any roles that have been detailed but are not within our
|
||||
// state.
|
||||
role, err := snap.GetACLRoleByID(nil, roleLink.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if role == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Unpack the policies held within the ACL role to form a single list
|
||||
// of ACL policies that this token has available.
|
||||
for _, policyLink := range role.Policies {
|
||||
policy, err := snap.ACLPolicyByName(nil, policyLink.Name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Ignore policies that don't exist, since they don't grant any
|
||||
// more privilege.
|
||||
if policy == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Add the policy to the tracking array.
|
||||
policies = append(policies, policy)
|
||||
}
|
||||
}
|
||||
|
||||
// Compile and cache the ACL object
|
||||
aclObj, err := structs.CompileACLObject(cache, policies)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return aclObj, nil
|
||||
}
|
||||
|
||||
// resolveSecretToken is used to translate an ACL Token Secret ID into a
|
||||
// Accessor ID, the anonymous accessor, or an error.
|
||||
func (s *Authenticator) resolveSecretToken(secretID string) (*structs.ACLToken, error) {
|
||||
|
||||
defer metrics.MeasureSince([]string{"nomad", "acl", "accessorForSecretToken"}, time.Now())
|
||||
|
||||
if !s.aclsEnabled {
|
||||
return structs.ACLsDisabledToken, nil
|
||||
}
|
||||
if secretID == "" {
|
||||
return structs.AnonymousACLToken, nil
|
||||
}
|
||||
if !helper.IsUUID(secretID) {
|
||||
return nil, structs.ErrTokenInvalid
|
||||
}
|
||||
|
||||
snap, err := s.getState().Snapshot()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Lookup the ACL Token
|
||||
token, err := snap.ACLTokenBySecretID(nil, secretID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if token == nil {
|
||||
return nil, structs.ErrTokenNotFound
|
||||
}
|
||||
if token.IsExpired(time.Now().UTC()) {
|
||||
return nil, structs.ErrTokenExpired
|
||||
}
|
||||
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func (s *Authenticator) ResolvePoliciesForClaims(claims *structs.IdentityClaims) ([]*structs.ACLPolicy, error) {
|
||||
|
||||
snap, err := s.getState().Snapshot()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
alloc, err := snap.AllocByID(nil, claims.AllocationID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if alloc == nil || alloc.Job == nil {
|
||||
return nil, fmt.Errorf("allocation does not exist")
|
||||
}
|
||||
|
||||
// Find any policies attached to the job
|
||||
jobId := alloc.Job.GetIDforWorkloadIdentity()
|
||||
iter, err := snap.ACLPolicyByJob(nil, alloc.Namespace, jobId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
policies := []*structs.ACLPolicy{}
|
||||
for {
|
||||
raw := iter.Next()
|
||||
if raw == nil {
|
||||
break
|
||||
}
|
||||
policy := raw.(*structs.ACLPolicy)
|
||||
if policy.JobACL == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
switch {
|
||||
case policy.JobACL.Group == "":
|
||||
policies = append(policies, policy)
|
||||
case policy.JobACL.Group != alloc.TaskGroup:
|
||||
continue // don't bother checking task
|
||||
case policy.JobACL.Task == "":
|
||||
policies = append(policies, policy)
|
||||
case policy.JobACL.Task == claims.TaskName:
|
||||
policies = append(policies, policy)
|
||||
}
|
||||
}
|
||||
|
||||
iter, err = snap.ACLPolicyByNamespace(nil, alloc.Namespace)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for {
|
||||
raw := iter.Next()
|
||||
if raw == nil {
|
||||
break
|
||||
}
|
||||
|
||||
policy := raw.(*structs.ACLPolicy)
|
||||
if policy.JobACL == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
policies = append(policies, policy)
|
||||
}
|
||||
|
||||
return policies, nil
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,234 +0,0 @@
|
||||
// Copyright IBM Corp. 2015, 2025
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package nomad
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
metrics "github.com/hashicorp/go-metrics/compat"
|
||||
"github.com/hashicorp/nomad/helper"
|
||||
"github.com/hashicorp/nomad/nomad/peers"
|
||||
"github.com/hashicorp/nomad/nomad/structs"
|
||||
"github.com/hashicorp/raft"
|
||||
autopilot "github.com/hashicorp/raft-autopilot"
|
||||
"github.com/hashicorp/serf/serf"
|
||||
)
|
||||
|
||||
const (
|
||||
// AutopilotRZTag is the Serf tag to use for the redundancy zone value
|
||||
// when passing the server metadata to Autopilot.
|
||||
AutopilotRZTag = "ap_zone"
|
||||
|
||||
// AutopilotRZTag is the Serf tag to use for the custom version value
|
||||
// when passing the server metadata to Autopilot.
|
||||
AutopilotVersionTag = "ap_version"
|
||||
)
|
||||
|
||||
// AutopilotDelegate is a Nomad delegate for autopilot operations. It implements
|
||||
// the autopilot.ApplicationIntegration interface, and the methods required for
|
||||
// that interface have been documented as such below.
|
||||
type AutopilotDelegate struct {
|
||||
server *Server
|
||||
}
|
||||
|
||||
// AutopilotConfig is used to retrieve the latest configuration from the Nomad
|
||||
// delegate. This method is required to implement the ApplicationIntegration
|
||||
// interface.
|
||||
func (d *AutopilotDelegate) AutopilotConfig() *autopilot.Config {
|
||||
c := d.server.getOrCreateAutopilotConfig()
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
conf := &autopilot.Config{
|
||||
CleanupDeadServers: c.CleanupDeadServers,
|
||||
LastContactThreshold: c.LastContactThreshold,
|
||||
MaxTrailingLogs: c.MaxTrailingLogs,
|
||||
MinQuorum: c.MinQuorum,
|
||||
ServerStabilizationTime: c.ServerStabilizationTime,
|
||||
Ext: autopilotConfigExt(c),
|
||||
}
|
||||
|
||||
return conf
|
||||
}
|
||||
|
||||
// FetchServerStats will be called by autopilot to request Nomad fetch the
|
||||
// server stats out of band. This method is required to implement the
|
||||
// ApplicationIntegration interface
|
||||
func (d *AutopilotDelegate) FetchServerStats(ctx context.Context, servers map[raft.ServerID]*autopilot.Server) map[raft.ServerID]*autopilot.ServerStats {
|
||||
return d.server.statsFetcher.Fetch(ctx, servers)
|
||||
}
|
||||
|
||||
// KnownServers will be called by autopilot to request the list of servers known
|
||||
// to Nomad. This method is required to implement the ApplicationIntegration
|
||||
// interface
|
||||
func (d *AutopilotDelegate) KnownServers() map[raft.ServerID]*autopilot.Server {
|
||||
return d.server.autopilotServers()
|
||||
}
|
||||
|
||||
// NotifyState will be called when the autopilot state is updated. The Nomad
|
||||
// leader heartbeats a metric for monitoring based on this information. This
|
||||
// method is required to implement the ApplicationIntegration interface
|
||||
func (d *AutopilotDelegate) NotifyState(state *autopilot.State) {
|
||||
if d.server.raft.State() == raft.Leader {
|
||||
metrics.SetGauge([]string{"nomad", "autopilot", "failure_tolerance"}, float32(state.FailureTolerance))
|
||||
if state.Healthy {
|
||||
metrics.SetGauge([]string{"nomad", "autopilot", "healthy"}, 1)
|
||||
} else {
|
||||
metrics.SetGauge([]string{"nomad", "autopilot", "healthy"}, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// RemoveFailedServer will be called by autopilot to notify Nomad to remove the
|
||||
// server in a failed state. This method is required to implement the
|
||||
// ApplicationIntegration interface. (Note this is expected to return
|
||||
// immediately so we'll spawn a goroutine for it.)
|
||||
func (d *AutopilotDelegate) RemoveFailedServer(failedSrv *autopilot.Server) {
|
||||
go func() {
|
||||
err := d.server.RemoveFailedNode(failedSrv.Name)
|
||||
if err != nil {
|
||||
d.server.logger.Error("could not remove failed server",
|
||||
"server", string(failedSrv.ID),
|
||||
"error", err,
|
||||
)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// MinRaftProtocol returns the lowest supported Raft protocol among alive
|
||||
// servers
|
||||
func (s *Server) MinRaftProtocol() (int, error) {
|
||||
return minRaftProtocol(s.peersCache.RegionPeers(s.Region()))
|
||||
}
|
||||
|
||||
// GetClusterHealth is used to get the current health of the servers, as known
|
||||
// by the leader.
|
||||
func (s *Server) GetClusterHealth() *structs.OperatorHealthReply {
|
||||
|
||||
state := s.autopilot.GetState()
|
||||
if state == nil {
|
||||
// this behavior seems odd but its functionally equivalent to 1.8.5 where if
|
||||
// autopilot didn't have a health reply yet it would just return no error
|
||||
return nil
|
||||
}
|
||||
|
||||
health := &structs.OperatorHealthReply{
|
||||
Healthy: state.Healthy,
|
||||
FailureTolerance: state.FailureTolerance,
|
||||
Leader: string(state.Leader),
|
||||
Voters: stringIDs(state.Voters),
|
||||
Servers: make([]structs.ServerHealth, 0, len(state.Servers)),
|
||||
}
|
||||
|
||||
for _, srv := range state.Servers {
|
||||
srvHealth := autopilotToServerHealth(srv)
|
||||
|
||||
health.Servers = append(health.Servers, srvHealth)
|
||||
}
|
||||
err := s.autopilotStateExt(state, health)
|
||||
if err != nil {
|
||||
s.logger.Error("Error parsing autopilot state", "error", err)
|
||||
}
|
||||
|
||||
return health
|
||||
}
|
||||
|
||||
// -------------------
|
||||
// helper functions
|
||||
|
||||
func autopilotToServerHealth(srv *autopilot.ServerState) structs.ServerHealth {
|
||||
srvHealth := structs.ServerHealth{
|
||||
ID: string(srv.Server.ID),
|
||||
Name: srv.Server.Name,
|
||||
Address: string(srv.Server.Address),
|
||||
Version: srv.Server.Version,
|
||||
Leader: srv.State == autopilot.RaftLeader,
|
||||
Voter: srv.State == autopilot.RaftLeader || srv.State == autopilot.RaftVoter,
|
||||
LastContact: srv.Stats.LastContact,
|
||||
LastTerm: srv.Stats.LastTerm,
|
||||
LastIndex: srv.Stats.LastIndex,
|
||||
Healthy: srv.Health.Healthy,
|
||||
StableSince: srv.Health.StableSince,
|
||||
}
|
||||
|
||||
switch srv.Server.NodeStatus {
|
||||
case autopilot.NodeAlive:
|
||||
srvHealth.SerfStatus = serf.StatusAlive
|
||||
case autopilot.NodeLeft:
|
||||
srvHealth.SerfStatus = serf.StatusLeft
|
||||
case autopilot.NodeFailed:
|
||||
srvHealth.SerfStatus = serf.StatusFailed
|
||||
default:
|
||||
srvHealth.SerfStatus = serf.StatusNone
|
||||
}
|
||||
|
||||
return srvHealth
|
||||
}
|
||||
|
||||
func stringIDs(ids []raft.ServerID) []string {
|
||||
return helper.ConvertSlice(ids, func(id raft.ServerID) string { return string(id) })
|
||||
}
|
||||
|
||||
func minRaftProtocol(members []*peers.Parts) (int, error) {
|
||||
minVersion := -1
|
||||
for _, m := range members {
|
||||
if m.Status != serf.StatusAlive {
|
||||
continue
|
||||
}
|
||||
|
||||
if minVersion == -1 || m.RaftVersion < minVersion {
|
||||
minVersion = m.RaftVersion
|
||||
}
|
||||
}
|
||||
|
||||
if minVersion == -1 {
|
||||
return minVersion, fmt.Errorf("No servers found")
|
||||
}
|
||||
|
||||
return minVersion, nil
|
||||
}
|
||||
|
||||
func (s *Server) autopilotServers() map[raft.ServerID]*autopilot.Server {
|
||||
|
||||
// Get a list of the regional peers for our local region. We cannot use the
|
||||
// LocalPeers function, as we need peers in all states.
|
||||
localPeers := s.peersCache.RegionPeers(s.Region())
|
||||
|
||||
servers := make(map[raft.ServerID]*autopilot.Server, len(localPeers))
|
||||
|
||||
for _, srv := range localPeers {
|
||||
autopilotServer := s.autopilotServerFromMetadata(srv)
|
||||
servers[autopilotServer.ID] = autopilotServer
|
||||
}
|
||||
|
||||
return servers
|
||||
}
|
||||
|
||||
func (s *Server) autopilotServerFromMetadata(srv *peers.Parts) *autopilot.Server {
|
||||
server := &autopilot.Server{
|
||||
Name: srv.Name,
|
||||
ID: raft.ServerID(srv.ID),
|
||||
Address: raft.ServerAddress(srv.Addr.String()),
|
||||
Version: srv.Build.String(),
|
||||
RaftVersion: srv.RaftVersion,
|
||||
Ext: s.autopilotServerExt(srv),
|
||||
Meta: srv.Tags,
|
||||
}
|
||||
|
||||
switch srv.Status {
|
||||
case serf.StatusLeft:
|
||||
server.NodeStatus = autopilot.NodeLeft
|
||||
case serf.StatusAlive, serf.StatusLeaving:
|
||||
// we want to treat leaving as alive to prevent autopilot from
|
||||
// prematurely removing the node.
|
||||
server.NodeStatus = autopilot.NodeAlive
|
||||
case serf.StatusFailed:
|
||||
server.NodeStatus = autopilot.NodeFailed
|
||||
default:
|
||||
server.NodeStatus = autopilot.NodeUnknown
|
||||
}
|
||||
|
||||
return server
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
// Copyright IBM Corp. 2015, 2025
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
//go:build !ent
|
||||
// +build !ent
|
||||
|
||||
package nomad
|
||||
|
||||
import (
|
||||
autopilot "github.com/hashicorp/raft-autopilot"
|
||||
|
||||
"github.com/hashicorp/nomad/nomad/peers"
|
||||
"github.com/hashicorp/nomad/nomad/structs"
|
||||
)
|
||||
|
||||
func (s *Server) autopilotPromoter() autopilot.Promoter {
|
||||
return autopilot.DefaultPromoter()
|
||||
}
|
||||
|
||||
// autopilotServerExt returns the autopilot-enterprise.Server extensions needed
|
||||
// for ENT feature support, but this is the empty OSS implementation.
|
||||
func (s *Server) autopilotServerExt(_ *peers.Parts) interface{} {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) autopilotStateExt(_ *autopilot.State, _ *structs.OperatorHealthReply) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// autopilotConfigExt returns the autopilot-enterprise.Config extensions needed
|
||||
// for ENT feature support, but this is the empty OSS implementation.
|
||||
func autopilotConfigExt(_ *structs.AutopilotConfig) interface{} {
|
||||
return nil
|
||||
}
|
||||
@@ -1,348 +0,0 @@
|
||||
// Copyright IBM Corp. 2015, 2025
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package nomad
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/hashicorp/raft"
|
||||
autopilot "github.com/hashicorp/raft-autopilot"
|
||||
"github.com/hashicorp/serf/serf"
|
||||
"github.com/shoenig/test/must"
|
||||
|
||||
"github.com/hashicorp/nomad/ci"
|
||||
"github.com/hashicorp/nomad/nomad/structs"
|
||||
"github.com/hashicorp/nomad/testutil"
|
||||
)
|
||||
|
||||
var _ autopilot.ApplicationIntegration = (*AutopilotDelegate)(nil)
|
||||
|
||||
// wantPeers determines whether the server has the given
|
||||
// number of voting raft peers.
|
||||
func wantPeers(s *Server, peers int) error {
|
||||
future := s.raft.GetConfiguration()
|
||||
if err := future.Error(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var n int
|
||||
for _, server := range future.Configuration().Servers {
|
||||
if server.Suffrage == raft.Voter {
|
||||
n++
|
||||
}
|
||||
}
|
||||
|
||||
if got, want := n, peers; got != want {
|
||||
return fmt.Errorf("server %v: got %d peers want %d\n\tservers: %#+v", s.config.NodeName, got, want, future.Configuration().Servers)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestAutopilot_CleanupDeadServer(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
|
||||
conf := func(c *Config) {
|
||||
c.NumSchedulers = 0 // reduces test log noise
|
||||
c.BootstrapExpect = 3
|
||||
c.RaftConfig.ProtocolVersion = raft.ProtocolVersion(3)
|
||||
}
|
||||
|
||||
s1, cleanupS1 := TestServer(t, conf)
|
||||
defer cleanupS1()
|
||||
|
||||
s2, cleanupS2 := TestServer(t, conf)
|
||||
defer cleanupS2()
|
||||
|
||||
s3, cleanupS3 := TestServer(t, conf)
|
||||
defer cleanupS3()
|
||||
|
||||
servers := []*Server{s1, s2, s3}
|
||||
TestJoin(t, servers...)
|
||||
|
||||
t.Logf("waiting for initial stable cluster")
|
||||
waitForStableLeadership(t, servers)
|
||||
|
||||
s4, cleanupS4 := TestServer(t, conf)
|
||||
defer cleanupS4()
|
||||
|
||||
// Kill a non-leader server
|
||||
killedIdx := 0
|
||||
for i, s := range servers {
|
||||
if !s.IsLeader() {
|
||||
killedIdx = i
|
||||
t.Logf("killing a server (index %d)", killedIdx)
|
||||
s.Shutdown()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
t.Logf("waiting for server loss to be detected")
|
||||
testutil.WaitForResultUntil(10*time.Second, func() (bool, error) {
|
||||
for i, s := range servers {
|
||||
alive := 0
|
||||
if i == killedIdx {
|
||||
// Skip shutdown server
|
||||
continue
|
||||
}
|
||||
for _, m := range s.Members() {
|
||||
if m.Status == serf.StatusAlive {
|
||||
alive++
|
||||
}
|
||||
}
|
||||
|
||||
if alive != 2 {
|
||||
return false, fmt.Errorf("expected 2 alive servers but found %v", alive)
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
}, func(err error) { must.NoError(t, err) })
|
||||
|
||||
// Join the new server
|
||||
servers[killedIdx] = s4
|
||||
t.Logf("adding server s4")
|
||||
TestJoin(t, servers...)
|
||||
|
||||
t.Logf("waiting for dead server to be removed")
|
||||
waitForStableLeadership(t, servers)
|
||||
}
|
||||
|
||||
func TestAutopilot_CleanupDeadServerPeriodic(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
|
||||
conf := func(c *Config) {
|
||||
c.NumSchedulers = 0 // reduces test log noise
|
||||
c.BootstrapExpect = 5
|
||||
}
|
||||
|
||||
s1, cleanupS1 := TestServer(t, conf)
|
||||
defer cleanupS1()
|
||||
|
||||
s2, cleanupS2 := TestServer(t, conf)
|
||||
defer cleanupS2()
|
||||
|
||||
s3, cleanupS3 := TestServer(t, conf)
|
||||
defer cleanupS3()
|
||||
|
||||
s4, cleanupS4 := TestServer(t, conf)
|
||||
defer cleanupS4()
|
||||
|
||||
s5, cleanupS5 := TestServer(t, conf)
|
||||
defer cleanupS5()
|
||||
|
||||
servers := []*Server{s1, s2, s3, s4, s5}
|
||||
TestJoin(t, servers...)
|
||||
|
||||
t.Logf("waiting for initial stable cluster")
|
||||
waitForStableLeadership(t, servers)
|
||||
|
||||
t.Logf("killing a non-leader server")
|
||||
if leader := waitForStableLeadership(t, servers); leader == s4 {
|
||||
s1, s4 = s4, s1
|
||||
}
|
||||
s4.Shutdown()
|
||||
|
||||
t.Logf("waiting for dead peer to be removed")
|
||||
servers = []*Server{s1, s2, s3, s5}
|
||||
waitForStableLeadership(t, servers)
|
||||
}
|
||||
|
||||
func TestAutopilot_RollingUpdate(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
|
||||
conf := func(c *Config) {
|
||||
c.NumSchedulers = 0 // reduces test log noise
|
||||
c.BootstrapExpect = 3
|
||||
c.RaftConfig.ProtocolVersion = 3
|
||||
}
|
||||
|
||||
s1, cleanupS1 := TestServer(t, conf)
|
||||
defer cleanupS1()
|
||||
|
||||
s2, cleanupS2 := TestServer(t, conf)
|
||||
defer cleanupS2()
|
||||
|
||||
s3, cleanupS3 := TestServer(t, conf)
|
||||
defer cleanupS3()
|
||||
|
||||
servers := []*Server{s1, s2, s3}
|
||||
TestJoin(t, s1, s2, s3)
|
||||
|
||||
t.Logf("waiting for initial stable cluster")
|
||||
waitForStableLeadership(t, servers)
|
||||
|
||||
// Add one more server like we are doing a rolling update.
|
||||
t.Logf("adding server s4")
|
||||
s4, cleanupS4 := TestServer(t, conf)
|
||||
defer cleanupS4()
|
||||
TestJoin(t, s1, s4)
|
||||
|
||||
// Wait for s4 to stabilize and get promoted to a voter
|
||||
t.Logf("waiting for s4 to stabilize and be promoted")
|
||||
servers = append(servers, s4)
|
||||
waitForStableLeadership(t, servers)
|
||||
|
||||
// Now kill one of the "old" nodes like we are doing a rolling update.
|
||||
t.Logf("shutting down server s3")
|
||||
s3.Shutdown()
|
||||
|
||||
// Wait for s3 to be removed and the cluster to stablize.
|
||||
t.Logf("waiting for cluster to stabilize")
|
||||
servers = []*Server{s1, s2, s4}
|
||||
waitForStableLeadership(t, servers)
|
||||
}
|
||||
|
||||
func TestAutopilot_MultiRegion(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
|
||||
conf := func(c *Config) {
|
||||
c.NumSchedulers = 0 // reduces test log noise
|
||||
c.BootstrapExpect = 3
|
||||
}
|
||||
s1, cleanupS1 := TestServer(t, conf)
|
||||
defer cleanupS1()
|
||||
|
||||
s2, cleanupS2 := TestServer(t, conf)
|
||||
defer cleanupS2()
|
||||
|
||||
s3, cleanupS3 := TestServer(t, conf)
|
||||
defer cleanupS3()
|
||||
|
||||
// federated regions should not be considered raft peers or show up in the
|
||||
// known servers list
|
||||
s4, cleanupS4 := TestServer(t, func(c *Config) {
|
||||
c.BootstrapExpect = 0
|
||||
c.Region = "other"
|
||||
})
|
||||
defer cleanupS4()
|
||||
|
||||
servers := []*Server{s1, s2, s3}
|
||||
TestJoin(t, s1, s2, s3, s4)
|
||||
|
||||
t.Logf("waiting for initial stable cluster")
|
||||
waitForStableLeadership(t, servers)
|
||||
|
||||
apDelegate := &AutopilotDelegate{s3}
|
||||
known := apDelegate.KnownServers()
|
||||
must.Eq(t, 3, len(known))
|
||||
|
||||
}
|
||||
|
||||
func TestAutopilot_CleanupStaleRaftServer(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
|
||||
conf := func(c *Config) {
|
||||
c.NumSchedulers = 0 // reduces test log noise
|
||||
c.BootstrapExpect = 3
|
||||
}
|
||||
s1, cleanupS1 := TestServer(t, conf)
|
||||
defer cleanupS1()
|
||||
|
||||
s2, cleanupS2 := TestServer(t, conf)
|
||||
defer cleanupS2()
|
||||
|
||||
s3, cleanupS3 := TestServer(t, conf)
|
||||
defer cleanupS3()
|
||||
|
||||
s4, cleanupS4 := TestServer(t, func(c *Config) {
|
||||
c.BootstrapExpect = 0
|
||||
})
|
||||
defer cleanupS4()
|
||||
|
||||
servers := []*Server{s1, s2, s3}
|
||||
TestJoin(t, s1, s2, s3)
|
||||
|
||||
t.Logf("waiting for initial stable cluster")
|
||||
leader := waitForStableLeadership(t, servers)
|
||||
|
||||
t.Logf("adding server s4 to peers directly")
|
||||
addr := fmt.Sprintf("127.0.0.1:%d", s4.config.RPCAddr.Port)
|
||||
future := leader.raft.AddVoter(raft.ServerID(s4.config.NodeID), raft.ServerAddress(addr), 0, 0)
|
||||
if err := future.Error(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
t.Logf("waiting for 4th server to be removed")
|
||||
waitForStableLeadership(t, servers)
|
||||
}
|
||||
|
||||
func TestAutopilot_PromoteNonVoter(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
|
||||
s1, cleanupS1 := TestServer(t, func(c *Config) {
|
||||
c.NumSchedulers = 0 // reduces test log noise
|
||||
c.RaftConfig.ProtocolVersion = 3
|
||||
})
|
||||
defer cleanupS1()
|
||||
codec := rpcClient(t, s1)
|
||||
defer codec.Close()
|
||||
testutil.WaitForLeader(t, s1.RPC)
|
||||
|
||||
s2, cleanupS2 := TestServer(t, func(c *Config) {
|
||||
c.NumSchedulers = 0 // reduces test log noise
|
||||
c.BootstrapExpect = 0
|
||||
c.RaftConfig.ProtocolVersion = 3
|
||||
})
|
||||
defer cleanupS2()
|
||||
TestJoin(t, s1, s2)
|
||||
|
||||
// Note: we can't reliably detect that the server is initially a non-voter,
|
||||
// because it can transition too quickly for the test setup to detect,
|
||||
// especially in low-resource environments like CI. We'll assume that
|
||||
// happens correctly here and only test that it transitions to become a
|
||||
// voter.
|
||||
testutil.WaitForResultUntil(10*time.Second, func() (bool, error) {
|
||||
future := s1.raft.GetConfiguration()
|
||||
if err := future.Error(); err != nil {
|
||||
return false, err
|
||||
}
|
||||
servers := future.Configuration().Servers
|
||||
if len(servers) != 2 {
|
||||
return false, fmt.Errorf("expected 2 servers, got: %v", servers)
|
||||
}
|
||||
if servers[1].Suffrage != raft.Voter {
|
||||
return false, fmt.Errorf("expected server to be voter: %v", servers)
|
||||
}
|
||||
return true, nil
|
||||
}, func(err error) { must.NoError(t, err) })
|
||||
}
|
||||
|
||||
func TestAutopilot_ReturnAutopilotHealth(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
s1, cleanupS1 := TestServer(t, func(c *Config) {
|
||||
c.BootstrapExpect = 2
|
||||
c.RaftConfig.ProtocolVersion = 3
|
||||
c.AutopilotConfig.EnableCustomUpgrades = true
|
||||
c.UpgradeVersion = "0.0.1"
|
||||
c.NumSchedulers = 0 // reduce log noise
|
||||
})
|
||||
defer cleanupS1()
|
||||
|
||||
s2, cleanupS2 := TestServer(t, func(c *Config) {
|
||||
c.BootstrapExpect = 2
|
||||
c.RaftConfig.ProtocolVersion = 3
|
||||
c.AutopilotConfig.EnableCustomUpgrades = true
|
||||
c.UpgradeVersion = "0.0.1"
|
||||
c.NumSchedulers = 0 // reduce log noise
|
||||
})
|
||||
defer cleanupS2()
|
||||
|
||||
TestJoin(t, s1, s2)
|
||||
servers := []*Server{s1, s2}
|
||||
leader := waitForStableLeadership(t, servers)
|
||||
|
||||
get := &structs.GenericRequest{
|
||||
QueryOptions: structs.QueryOptions{
|
||||
Region: "global",
|
||||
},
|
||||
}
|
||||
reply := &structs.OperatorHealthReply{}
|
||||
err := s1.RPC("Operator.ServerHealth", get, reply)
|
||||
must.NoError(t, err)
|
||||
|
||||
must.Eq(t, reply.Healthy, true)
|
||||
_, leaderID := leader.raft.LeaderWithID()
|
||||
must.Eq(t, reply.Leader, string(leaderID))
|
||||
}
|
||||
@@ -1,874 +0,0 @@
|
||||
// Copyright IBM Corp. 2015, 2025
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package nomad
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/hashicorp/go-hclog"
|
||||
metrics "github.com/hashicorp/go-metrics/compat"
|
||||
"github.com/hashicorp/nomad/helper"
|
||||
"github.com/hashicorp/nomad/nomad/structs"
|
||||
)
|
||||
|
||||
const (
|
||||
// unblockBuffer is the buffer size for the unblock channel. The buffer
|
||||
// should be large to ensure that the FSM doesn't block when calling Unblock
|
||||
// as this would apply back-pressure on Raft.
|
||||
unblockBuffer = 8096
|
||||
|
||||
// pruneInterval is the interval at which we prune objects from the
|
||||
// BlockedEvals tracker
|
||||
pruneInterval = 5 * time.Minute
|
||||
|
||||
// pruneThreshold is the threshold after which objects will be pruned.
|
||||
pruneThreshold = 15 * time.Minute
|
||||
)
|
||||
|
||||
// BlockedEvals is used to track evaluations that shouldn't be queued until a
|
||||
// certain class of nodes becomes available. An evaluation is put into the
|
||||
// blocked state when it is run through the scheduler and produced failed
|
||||
// allocations. It is unblocked when the capacity of a node that could run the
|
||||
// failed allocation becomes available.
|
||||
type BlockedEvals struct {
|
||||
// logger is the logger to use by the blocked eval tracker.
|
||||
logger hclog.Logger
|
||||
|
||||
evalBroker *EvalBroker
|
||||
enabled bool
|
||||
|
||||
// the lock on the enabled flag and the control channels like the stopCh and
|
||||
// duplicateCh. This is read by most top-level methods including those
|
||||
// called from the FSM, but write-locked only in Flush called during
|
||||
// leadership changes
|
||||
flushLock sync.RWMutex
|
||||
|
||||
stats *BlockedStats
|
||||
|
||||
// l is the lock on the data we're tracking
|
||||
l sync.RWMutex
|
||||
|
||||
// captured is the set of evaluations that are captured by computed node
|
||||
// classes.
|
||||
captured map[string]wrappedEval
|
||||
|
||||
// escaped is the set of evaluations that have escaped computed node
|
||||
// classes.
|
||||
escaped map[string]wrappedEval
|
||||
|
||||
// system is the set of system evaluations that failed to start on nodes because of
|
||||
// resource constraints.
|
||||
system *systemEvals
|
||||
|
||||
// capacityChangeCh is used to buffer unblocking of evaluations.
|
||||
capacityChangeCh chan *capacityUpdate
|
||||
|
||||
// jobs is the map of blocked job and is used to ensure that only one
|
||||
// blocked eval exists for each job. The value is the blocked evaluation ID.
|
||||
jobs map[structs.NamespacedID]string
|
||||
|
||||
// unblockIndexes maps computed node classes or quota name to the index and
|
||||
// time at which they were unblocked. This is used to check if an
|
||||
// evaluation could have been unblocked between the time they were in the
|
||||
// scheduler and the time they are being blocked.
|
||||
unblockIndexes map[string]unblockEvent
|
||||
|
||||
// unblockIndexesLock protects unblockIndexes, which has its own lock
|
||||
// because we want to take a write lock in the Unblock* methods called from
|
||||
// the FSM
|
||||
unblockIndexesLock sync.RWMutex
|
||||
|
||||
// duplicates is the set of evaluations for jobs that had pre-existing
|
||||
// blocked evaluations. These should be marked as cancelled since only one
|
||||
// blocked eval is needed per job.
|
||||
duplicates []*structs.Evaluation
|
||||
|
||||
// duplicateCh is used to signal that a duplicate eval was added to the
|
||||
// duplicate set. It can be used to unblock waiting callers looking for
|
||||
// duplicates.
|
||||
duplicateCh chan struct{}
|
||||
|
||||
// stopCh is used to stop any created goroutines.
|
||||
stopCh chan struct{}
|
||||
}
|
||||
|
||||
// unblockEvent keeps a record of the index and time of the unblock
|
||||
type unblockEvent struct {
|
||||
index uint64
|
||||
timestamp time.Time
|
||||
}
|
||||
|
||||
// capacityUpdate stores unblock data.
|
||||
type capacityUpdate struct {
|
||||
computedClass string
|
||||
quotaChange string
|
||||
nodeID string
|
||||
|
||||
blockedEval *structs.Evaluation
|
||||
blockToken string
|
||||
untrackJobID structs.NamespacedID
|
||||
|
||||
// future will be closed when the operation is done, which allows callers to
|
||||
// block until then if they need to (this is mostly used for tests). The
|
||||
// callers in the FSM should never wait on this future
|
||||
future chan struct{}
|
||||
}
|
||||
|
||||
// wrappedEval captures both the evaluation and the optional token
|
||||
type wrappedEval struct {
|
||||
eval *structs.Evaluation
|
||||
token string
|
||||
}
|
||||
|
||||
// NewBlockedEvals creates a new blocked eval tracker that will enqueue
|
||||
// unblocked evals into the passed broker.
|
||||
func NewBlockedEvals(evalBroker *EvalBroker, logger hclog.Logger) *BlockedEvals {
|
||||
return &BlockedEvals{
|
||||
logger: logger.Named("blocked_evals"),
|
||||
evalBroker: evalBroker,
|
||||
captured: make(map[string]wrappedEval),
|
||||
escaped: make(map[string]wrappedEval),
|
||||
system: newSystemEvals(),
|
||||
jobs: make(map[structs.NamespacedID]string),
|
||||
unblockIndexes: make(map[string]unblockEvent),
|
||||
capacityChangeCh: make(chan *capacityUpdate, unblockBuffer),
|
||||
duplicateCh: make(chan struct{}, 1),
|
||||
stopCh: make(chan struct{}),
|
||||
stats: NewBlockedStats(),
|
||||
}
|
||||
}
|
||||
|
||||
// Enabled is used to check if the broker is enabled.
|
||||
func (b *BlockedEvals) Enabled() bool {
|
||||
b.flushLock.RLock()
|
||||
defer b.flushLock.RUnlock()
|
||||
return b.enabled
|
||||
}
|
||||
|
||||
// SetEnabled is used to control if the blocked eval tracker is enabled. The
|
||||
// tracker should only be enabled on the active leader.
|
||||
func (b *BlockedEvals) SetEnabled(enabled bool) {
|
||||
b.flushLock.Lock()
|
||||
b.l.Lock()
|
||||
if b.enabled == enabled {
|
||||
// No-op
|
||||
b.l.Unlock()
|
||||
b.flushLock.Unlock()
|
||||
return
|
||||
} else if enabled {
|
||||
go b.watchCapacity(b.stopCh, b.capacityChangeCh)
|
||||
go b.prune(b.stopCh)
|
||||
} else {
|
||||
close(b.stopCh)
|
||||
}
|
||||
b.enabled = enabled
|
||||
b.l.Unlock()
|
||||
b.flushLock.Unlock()
|
||||
if !enabled {
|
||||
b.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
// Block tracks the passed evaluation and enqueues it into the eval broker when
|
||||
// a suitable node calls unblock.
|
||||
func (b *BlockedEvals) Block(eval *structs.Evaluation) chan struct{} {
|
||||
fut := make(chan struct{})
|
||||
|
||||
b.flushLock.RLock()
|
||||
defer b.flushLock.RUnlock()
|
||||
if !b.enabled {
|
||||
close(fut)
|
||||
return fut
|
||||
}
|
||||
|
||||
// Capture chan in flushlock as Flush overwrites it
|
||||
ch := b.capacityChangeCh
|
||||
done := b.stopCh
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case ch <- &capacityUpdate{blockedEval: eval, future: fut}:
|
||||
}
|
||||
|
||||
return fut
|
||||
}
|
||||
|
||||
// Reblock tracks the passed evaluation and enqueues it into the eval broker when
|
||||
// a suitable node calls unblock. Reblock should be used over Block when the
|
||||
// blocking is occurring by an outstanding evaluation. The token is the
|
||||
// evaluation's token.
|
||||
func (b *BlockedEvals) Reblock(eval *structs.Evaluation, token string) chan struct{} {
|
||||
fut := make(chan struct{})
|
||||
|
||||
b.flushLock.RLock()
|
||||
defer b.flushLock.RUnlock()
|
||||
if !b.enabled {
|
||||
close(fut)
|
||||
return fut
|
||||
}
|
||||
|
||||
// Capture chan in flushlock as Flush overwrites it
|
||||
ch := b.capacityChangeCh
|
||||
done := b.stopCh
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case ch <- &capacityUpdate{blockedEval: eval, blockToken: token, future: fut}:
|
||||
}
|
||||
|
||||
return fut
|
||||
}
|
||||
|
||||
// processBlock is the implementation of blocking an evaluation. It supports
|
||||
// taking an optional evaluation token to use when reblocking an evaluation that
|
||||
// may be outstanding.
|
||||
func (b *BlockedEvals) processBlock(eval *structs.Evaluation, token string) {
|
||||
b.flushLock.RLock()
|
||||
defer b.flushLock.RUnlock()
|
||||
if !b.enabled {
|
||||
return
|
||||
}
|
||||
|
||||
b.l.Lock()
|
||||
defer b.l.Unlock()
|
||||
|
||||
// Handle the new evaluation being for a job we are already tracking.
|
||||
if b.processBlockJobDuplicate(eval) {
|
||||
// If process block job duplicate returns true, the new evaluation has
|
||||
// been marked as a duplicate and we have nothing to do, so return
|
||||
// early.
|
||||
return
|
||||
}
|
||||
|
||||
// Check if the eval missed an unblock while it was in the scheduler at an
|
||||
// older index. The scheduler could have been invoked with a snapshot of
|
||||
// state that was prior to additional capacity being added or allocations
|
||||
// becoming terminal.
|
||||
if b.missedUnblock(eval) {
|
||||
// Just re-enqueue the eval immediately. We pass the token so that the
|
||||
// eval_broker can properly handle the case in which the evaluation is
|
||||
// still outstanding.
|
||||
b.evalBroker.EnqueueAll(map[*structs.Evaluation]string{eval: token})
|
||||
return
|
||||
}
|
||||
|
||||
// Mark the job as tracked.
|
||||
b.jobs[structs.NewNamespacedID(eval.JobID, eval.Namespace)] = eval.ID
|
||||
b.stats.Block(eval)
|
||||
|
||||
// Wrap the evaluation, capturing its token.
|
||||
wrapped := wrappedEval{
|
||||
eval: eval,
|
||||
token: token,
|
||||
}
|
||||
|
||||
// If the eval has escaped, meaning computed node classes could not capture
|
||||
// the constraints of the job, we store the eval separately as we have to
|
||||
// unblock it whenever node capacity changes. This is because we don't know
|
||||
// what node class is feasible for the jobs constraints.
|
||||
if eval.EscapedComputedClass {
|
||||
b.escaped[eval.ID] = wrapped
|
||||
return
|
||||
}
|
||||
|
||||
// System evals are indexed by node and re-processed on utilization changes in
|
||||
// existing nodes
|
||||
if eval.Type == structs.JobTypeSystem {
|
||||
b.system.Add(eval, token)
|
||||
}
|
||||
|
||||
// Add the eval to the set of blocked evals whose jobs constraints are
|
||||
// captured by computed node class.
|
||||
b.captured[eval.ID] = wrapped
|
||||
}
|
||||
|
||||
// processBlockJobDuplicate handles the case where the new eval is for a job
|
||||
// that we are already tracking. If the eval is a duplicate, we add the older
|
||||
// evaluation by Raft index to the list of duplicates such that it can be
|
||||
// cancelled. We only ever want one blocked evaluation per job, otherwise we
|
||||
// would create unnecessary work for the scheduler as multiple evals for the
|
||||
// same job would be run, all producing the same outcome. It is critical to
|
||||
// prefer the newer evaluation, since it will contain the most up to date set of
|
||||
// class eligibility. The return value is set to true, if the passed evaluation
|
||||
// is cancelled. This should be called with the lock held.
|
||||
func (b *BlockedEvals) processBlockJobDuplicate(eval *structs.Evaluation) (newCancelled bool) {
|
||||
existingID, hasExisting := b.jobs[structs.NewNamespacedID(eval.JobID, eval.Namespace)]
|
||||
if !hasExisting {
|
||||
return
|
||||
}
|
||||
|
||||
var dup *structs.Evaluation
|
||||
existingW, ok := b.captured[existingID]
|
||||
if ok {
|
||||
if latestEvalIndex(existingW.eval) <= latestEvalIndex(eval) {
|
||||
delete(b.captured, existingID)
|
||||
dup = existingW.eval
|
||||
b.stats.Unblock(dup, false)
|
||||
} else {
|
||||
dup = eval
|
||||
newCancelled = true
|
||||
}
|
||||
} else {
|
||||
existingW, ok = b.escaped[existingID]
|
||||
if !ok {
|
||||
// This is a programming error
|
||||
b.logger.Error("existing blocked evaluation is neither tracked as captured or escaped", "existing_id", existingID)
|
||||
delete(b.jobs, structs.NewNamespacedID(eval.JobID, eval.Namespace))
|
||||
return
|
||||
}
|
||||
|
||||
if latestEvalIndex(existingW.eval) <= latestEvalIndex(eval) {
|
||||
delete(b.escaped, existingID)
|
||||
b.stats.decrementEscaped()
|
||||
dup = existingW.eval
|
||||
} else {
|
||||
dup = eval
|
||||
newCancelled = true
|
||||
}
|
||||
}
|
||||
|
||||
b.duplicates = append(b.duplicates, dup)
|
||||
|
||||
// Unblock any waiter.
|
||||
select {
|
||||
case b.duplicateCh <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// latestEvalIndex returns the max of the evaluations create and snapshot index
|
||||
func latestEvalIndex(eval *structs.Evaluation) uint64 {
|
||||
if eval == nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
return max(eval.CreateIndex, eval.SnapshotIndex)
|
||||
}
|
||||
|
||||
// missedUnblock returns whether an evaluation missed an unblock while it was in
|
||||
// the scheduler. Since the scheduler can operate at an index in the past, the
|
||||
// evaluation may have been processed missing data that would allow it to
|
||||
// complete. This method returns if that is the case and should be called with
|
||||
// the lock held.
|
||||
func (b *BlockedEvals) missedUnblock(eval *structs.Evaluation) bool {
|
||||
b.unblockIndexesLock.RLock()
|
||||
defer b.unblockIndexesLock.RUnlock()
|
||||
|
||||
var max uint64 = 0
|
||||
|
||||
for id, u := range b.unblockIndexes {
|
||||
// Calculate the max unblock index
|
||||
if max < u.index {
|
||||
max = u.index
|
||||
}
|
||||
|
||||
// The evaluation is blocked because it has hit a quota limit not class
|
||||
// eligibility
|
||||
if eval.QuotaLimitReached != "" {
|
||||
if eval.QuotaLimitReached != id {
|
||||
// Not a match
|
||||
continue
|
||||
} else if eval.SnapshotIndex < u.index {
|
||||
// The evaluation was processed before the quota specification was
|
||||
// updated, so unblock the evaluation.
|
||||
return true
|
||||
}
|
||||
|
||||
// The evaluation was processed having seen all changes to the quota
|
||||
return false
|
||||
}
|
||||
|
||||
elig, ok := eval.ClassEligibility[id]
|
||||
if !ok && eval.SnapshotIndex < u.index {
|
||||
// The evaluation was processed and did not encounter this class
|
||||
// because it was added after it was processed. Thus for correctness
|
||||
// we need to unblock it.
|
||||
return true
|
||||
}
|
||||
|
||||
// The evaluation could use the computed node class and the eval was
|
||||
// processed before the last unblock.
|
||||
if elig && eval.SnapshotIndex < u.index {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// If the evaluation has escaped, and the map contains an index older than
|
||||
// the evaluations, it should be unblocked.
|
||||
if eval.EscapedComputedClass && eval.SnapshotIndex < max {
|
||||
return true
|
||||
}
|
||||
|
||||
// The evaluation is ahead of all recent unblocks.
|
||||
return false
|
||||
}
|
||||
|
||||
// Untrack causes any blocked evaluation for the passed job to be no longer
|
||||
// tracked. Untrack is called when there is a successful evaluation for the job
|
||||
// and a blocked evaluation is no longer needed.
|
||||
func (b *BlockedEvals) Untrack(jobID, namespace string) chan struct{} {
|
||||
fut := make(chan struct{})
|
||||
b.flushLock.RLock()
|
||||
defer b.flushLock.RUnlock()
|
||||
if !b.enabled {
|
||||
close(fut)
|
||||
return fut
|
||||
}
|
||||
|
||||
// Capture chan in flushlock as Flush overwrites it
|
||||
ch := b.capacityChangeCh
|
||||
done := b.stopCh
|
||||
|
||||
nsID := structs.NewNamespacedID(jobID, namespace)
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case ch <- &capacityUpdate{untrackJobID: nsID, future: fut}:
|
||||
}
|
||||
|
||||
return fut
|
||||
}
|
||||
|
||||
func (b *BlockedEvals) untrackImpl(nsID structs.NamespacedID) {
|
||||
b.flushLock.RLock()
|
||||
defer b.flushLock.RUnlock()
|
||||
if !b.enabled {
|
||||
return
|
||||
}
|
||||
|
||||
b.l.Lock()
|
||||
defer b.l.Unlock()
|
||||
|
||||
if evals, ok := b.system.JobEvals(nsID); ok {
|
||||
for _, e := range evals {
|
||||
b.system.Remove(e)
|
||||
b.stats.Unblock(e, false)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Get the evaluation ID to cancel
|
||||
evalID, ok := b.jobs[nsID]
|
||||
if !ok {
|
||||
// No blocked evaluation so exit
|
||||
return
|
||||
}
|
||||
|
||||
// Attempt to delete the evaluation
|
||||
if w, ok := b.captured[evalID]; ok {
|
||||
delete(b.jobs, nsID)
|
||||
delete(b.captured, evalID)
|
||||
b.stats.Unblock(w.eval, false)
|
||||
}
|
||||
|
||||
if w, ok := b.escaped[evalID]; ok {
|
||||
delete(b.jobs, nsID)
|
||||
delete(b.escaped, evalID)
|
||||
b.stats.Unblock(w.eval, true)
|
||||
}
|
||||
}
|
||||
|
||||
// Unblock causes any evaluation that could potentially make progress on a
|
||||
// capacity change on the passed computed node class to be enqueued into the
|
||||
// eval broker.
|
||||
func (b *BlockedEvals) Unblock(computedClass string, index uint64) chan struct{} {
|
||||
fut := make(chan struct{})
|
||||
|
||||
b.flushLock.RLock()
|
||||
defer b.flushLock.RUnlock()
|
||||
if !b.enabled {
|
||||
close(fut)
|
||||
return fut
|
||||
}
|
||||
// Capture chan in flushlock as Flush overwrites it
|
||||
ch := b.capacityChangeCh
|
||||
done := b.stopCh
|
||||
|
||||
// Store the index in which the unblock happened. We use this on subsequent
|
||||
// block calls in case the evaluation was in the scheduler when a trigger
|
||||
// occurred.
|
||||
b.unblockIndexesLock.Lock()
|
||||
b.unblockIndexes[computedClass] = unblockEvent{index, time.Now().UTC()}
|
||||
b.unblockIndexesLock.Unlock()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case ch <- &capacityUpdate{computedClass: computedClass, future: fut}:
|
||||
}
|
||||
|
||||
return fut
|
||||
}
|
||||
|
||||
// UnblockQuota causes any evaluation that could potentially make progress on a
|
||||
// capacity change on the passed quota to be enqueued into the eval broker.
|
||||
func (b *BlockedEvals) UnblockQuota(quota string, index uint64) chan struct{} {
|
||||
fut := make(chan struct{})
|
||||
|
||||
// Nothing to do
|
||||
if quota == "" {
|
||||
return fut
|
||||
}
|
||||
|
||||
b.flushLock.RLock()
|
||||
defer b.flushLock.RUnlock()
|
||||
if !b.enabled {
|
||||
close(fut)
|
||||
return fut
|
||||
}
|
||||
// Capture chan in flushlock as Flush overwrites it
|
||||
ch := b.capacityChangeCh
|
||||
done := b.stopCh
|
||||
|
||||
// Store the index in which the unblock happened. We use this on subsequent
|
||||
// block calls in case the evaluation was in the scheduler when a trigger
|
||||
// occurred.
|
||||
b.unblockIndexesLock.Lock()
|
||||
b.unblockIndexes[quota] = unblockEvent{index, time.Now().UTC()}
|
||||
b.unblockIndexesLock.Unlock()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case ch <- &capacityUpdate{quotaChange: quota, future: fut}:
|
||||
}
|
||||
|
||||
return fut
|
||||
}
|
||||
|
||||
// UnblockClassAndQuota causes any evaluation that could potentially make
|
||||
// progress on a capacity change on the passed computed node class or quota to
|
||||
// be enqueued into the eval broker.
|
||||
func (b *BlockedEvals) UnblockClassAndQuota(class, quota string, index uint64) chan struct{} {
|
||||
fut := make(chan struct{})
|
||||
|
||||
b.flushLock.RLock()
|
||||
defer b.flushLock.RUnlock()
|
||||
if !b.enabled {
|
||||
close(fut)
|
||||
return fut
|
||||
}
|
||||
// Capture chan in flushlock as Flush overwrites it
|
||||
ch := b.capacityChangeCh
|
||||
done := b.stopCh
|
||||
|
||||
// Store the index in which the unblock happened. We use this on subsequent
|
||||
// block calls in case the evaluation was in the scheduler when a trigger
|
||||
// occurred.
|
||||
b.unblockIndexesLock.Lock()
|
||||
now := time.Now().UTC()
|
||||
if quota != "" {
|
||||
b.unblockIndexes[quota] = unblockEvent{index, now}
|
||||
}
|
||||
b.unblockIndexes[class] = unblockEvent{index, now}
|
||||
b.unblockIndexesLock.Unlock()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case ch <- &capacityUpdate{computedClass: class, quotaChange: quota, future: fut}:
|
||||
}
|
||||
|
||||
return fut
|
||||
}
|
||||
|
||||
// UnblockNode finds any blocked evalution that's node specific (system jobs)
|
||||
// and enqueues it on the eval broker.
|
||||
func (b *BlockedEvals) UnblockNode(nodeID string) chan struct{} {
|
||||
fut := make(chan struct{})
|
||||
|
||||
b.flushLock.RLock()
|
||||
defer b.flushLock.RUnlock()
|
||||
if !b.enabled {
|
||||
close(fut)
|
||||
return fut
|
||||
}
|
||||
// Capture chan in flushlock as Flush overwrites it
|
||||
ch := b.capacityChangeCh
|
||||
done := b.stopCh
|
||||
|
||||
// Note that unlike other unblock methods, we can't usefully track an
|
||||
// unblockIndex because any blocked eval won't have the node ID attached
|
||||
// when we check the index.
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case ch <- &capacityUpdate{nodeID: nodeID, future: fut}:
|
||||
}
|
||||
|
||||
return fut
|
||||
}
|
||||
|
||||
// watchCapacity is a long lived function that watches for capacity changes in
|
||||
// nodes and unblocks the correct set of evals.
|
||||
func (b *BlockedEvals) watchCapacity(
|
||||
stopCh <-chan struct{},
|
||||
changeCh <-chan *capacityUpdate,
|
||||
) {
|
||||
for {
|
||||
select {
|
||||
case <-stopCh:
|
||||
return
|
||||
case update := <-changeCh:
|
||||
if update.blockedEval != nil {
|
||||
b.processBlock(update.blockedEval, update.blockToken)
|
||||
close(update.future)
|
||||
continue
|
||||
}
|
||||
if update.untrackJobID.ID != "" {
|
||||
b.untrackImpl(update.untrackJobID)
|
||||
close(update.future)
|
||||
continue
|
||||
}
|
||||
|
||||
b.unblock(update.computedClass, update.quotaChange, update.nodeID)
|
||||
close(update.future)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (b *BlockedEvals) unblock(computedClass, quota, nodeID string) {
|
||||
|
||||
// Protect against the case of a flush.
|
||||
b.flushLock.RLock()
|
||||
defer b.flushLock.RUnlock()
|
||||
if !b.enabled {
|
||||
return
|
||||
}
|
||||
|
||||
b.l.Lock()
|
||||
defer b.l.Unlock()
|
||||
|
||||
// Every eval that has escaped computed node class has to be unblocked
|
||||
// because any node could potentially be feasible.
|
||||
numEscaped := len(b.escaped)
|
||||
unblocked := make(map[*structs.Evaluation]string, max(uint64(numEscaped), 4))
|
||||
|
||||
if numEscaped != 0 && computedClass != "" {
|
||||
for id, wrapped := range b.escaped {
|
||||
unblocked[wrapped.eval] = wrapped.token
|
||||
delete(b.escaped, id)
|
||||
delete(b.jobs, structs.NewNamespacedID(wrapped.eval.JobID, wrapped.eval.Namespace))
|
||||
}
|
||||
}
|
||||
|
||||
if quota == "" && computedClass == "" {
|
||||
goto SKIP_TO_NODE
|
||||
}
|
||||
|
||||
// We unblock any eval that is explicitly eligible for the computed class
|
||||
// and also any eval that is not eligible or uneligible. This signifies that
|
||||
// when the evaluation was originally run through the scheduler, that it
|
||||
// never saw a node with the given computed class and thus needs to be
|
||||
// unblocked for correctness.
|
||||
for id, wrapped := range b.captured {
|
||||
if quota != "" &&
|
||||
wrapped.eval.QuotaLimitReached != "" &&
|
||||
wrapped.eval.QuotaLimitReached != quota {
|
||||
// We are unblocking based on quota and this eval doesn't match
|
||||
continue
|
||||
}
|
||||
if elig, ok := wrapped.eval.ClassEligibility[computedClass]; ok && !elig {
|
||||
// Can skip because the eval has explicitly marked the node class
|
||||
// as ineligible.
|
||||
continue
|
||||
}
|
||||
|
||||
// Unblock the evaluation because it is either for the matching quota,
|
||||
// is eligible based on the computed node class, or never seen the
|
||||
// computed node class.
|
||||
unblocked[wrapped.eval] = wrapped.token
|
||||
delete(b.jobs, structs.NewNamespacedID(wrapped.eval.JobID, wrapped.eval.Namespace))
|
||||
delete(b.captured, id)
|
||||
}
|
||||
|
||||
SKIP_TO_NODE:
|
||||
if nodeID != "" {
|
||||
evals, _ := b.system.NodeEvals(nodeID)
|
||||
for eval, token := range evals {
|
||||
b.system.Remove(eval)
|
||||
unblocked[eval] = token
|
||||
}
|
||||
}
|
||||
|
||||
if len(unblocked) != 0 {
|
||||
// Update the counters and reset TotalEscaped
|
||||
b.stats.UnblockAll(unblocked, -1)
|
||||
|
||||
// Enqueue all the unblocked evals into the broker.
|
||||
b.evalBroker.EnqueueAll(unblocked)
|
||||
}
|
||||
}
|
||||
|
||||
// UnblockFailed unblocks all blocked evaluation that were due to scheduler
|
||||
// failure.
|
||||
func (b *BlockedEvals) UnblockFailed() {
|
||||
b.flushLock.RLock()
|
||||
defer b.flushLock.RUnlock()
|
||||
if !b.enabled {
|
||||
return
|
||||
}
|
||||
|
||||
b.l.Lock()
|
||||
defer b.l.Unlock()
|
||||
|
||||
escaped := 0
|
||||
unblocked := make(map[*structs.Evaluation]string, 4)
|
||||
for id, wrapped := range b.captured {
|
||||
if wrapped.eval.TriggeredBy == structs.EvalTriggerMaxPlans {
|
||||
unblocked[wrapped.eval] = wrapped.token
|
||||
delete(b.captured, id)
|
||||
delete(b.jobs, structs.NewNamespacedID(wrapped.eval.JobID, wrapped.eval.Namespace))
|
||||
}
|
||||
}
|
||||
|
||||
for id, wrapped := range b.escaped {
|
||||
if wrapped.eval.TriggeredBy == structs.EvalTriggerMaxPlans {
|
||||
unblocked[wrapped.eval] = wrapped.token
|
||||
delete(b.escaped, id)
|
||||
delete(b.jobs, structs.NewNamespacedID(wrapped.eval.JobID, wrapped.eval.Namespace))
|
||||
escaped++
|
||||
}
|
||||
}
|
||||
|
||||
if len(unblocked) > 0 {
|
||||
b.stats.UnblockAll(unblocked, escaped)
|
||||
b.evalBroker.EnqueueAll(unblocked)
|
||||
}
|
||||
}
|
||||
|
||||
// GetDuplicates returns all the duplicate evaluations and blocks until the
|
||||
// passed timeout.
|
||||
func (b *BlockedEvals) GetDuplicates(timeout time.Duration) []*structs.Evaluation {
|
||||
var timeoutTimer *time.Timer
|
||||
var timeoutCh <-chan time.Time
|
||||
SCAN:
|
||||
b.l.Lock()
|
||||
if len(b.duplicates) != 0 {
|
||||
dups := b.duplicates
|
||||
b.duplicates = nil
|
||||
b.l.Unlock()
|
||||
return dups
|
||||
}
|
||||
|
||||
// Capture chans inside the lock to prevent a race with them getting
|
||||
// reset in Flush
|
||||
dupCh := b.duplicateCh
|
||||
stopCh := b.stopCh
|
||||
b.l.Unlock()
|
||||
|
||||
// Create the timer
|
||||
if timeoutTimer == nil && timeout != 0 {
|
||||
timeoutTimer = time.NewTimer(timeout)
|
||||
timeoutCh = timeoutTimer.C
|
||||
defer timeoutTimer.Stop()
|
||||
}
|
||||
|
||||
select {
|
||||
case <-stopCh:
|
||||
return nil
|
||||
case <-timeoutCh:
|
||||
return nil
|
||||
case <-dupCh:
|
||||
goto SCAN
|
||||
}
|
||||
}
|
||||
|
||||
// Flush is used to clear the state of blocked evaluations.
|
||||
func (b *BlockedEvals) Flush() {
|
||||
b.flushLock.Lock()
|
||||
defer b.flushLock.Unlock()
|
||||
|
||||
b.l.Lock()
|
||||
defer b.l.Unlock()
|
||||
|
||||
// Reset stats
|
||||
b.stats.Reset()
|
||||
|
||||
// Reset the tracker
|
||||
b.captured = make(map[string]wrappedEval)
|
||||
b.escaped = make(map[string]wrappedEval)
|
||||
b.jobs = make(map[structs.NamespacedID]string)
|
||||
b.unblockIndexes = make(map[string]unblockEvent)
|
||||
b.duplicates = nil
|
||||
b.capacityChangeCh = make(chan *capacityUpdate, unblockBuffer)
|
||||
b.stopCh = make(chan struct{})
|
||||
b.duplicateCh = make(chan struct{}, 1)
|
||||
b.system = newSystemEvals()
|
||||
}
|
||||
|
||||
// EmitStats is used to export metrics about the blocked eval tracker while enabled
|
||||
func (b *BlockedEvals) EmitStats(period time.Duration, stopCh <-chan struct{}) {
|
||||
timer, stop := helper.NewSafeTimer(period)
|
||||
defer stop()
|
||||
|
||||
for {
|
||||
timer.Reset(period)
|
||||
|
||||
select {
|
||||
|
||||
case <-timer.C:
|
||||
stats := b.stats.Copy()
|
||||
metrics.SetGauge([]string{"nomad", "blocked_evals", "total_quota_limit"}, float32(stats.TotalQuotaLimit))
|
||||
metrics.SetGauge([]string{"nomad", "blocked_evals", "total_blocked"}, float32(stats.TotalBlocked))
|
||||
metrics.SetGauge([]string{"nomad", "blocked_evals", "total_escaped"}, float32(stats.TotalEscaped))
|
||||
|
||||
for k, v := range stats.BlockedResources.ByJob {
|
||||
labels := []metrics.Label{
|
||||
{Name: "namespace", Value: k.Namespace},
|
||||
{Name: "job", Value: k.ID},
|
||||
}
|
||||
metrics.SetGaugeWithLabels([]string{"nomad", "blocked_evals", "job", "cpu"}, float32(v.CPU), labels)
|
||||
metrics.SetGaugeWithLabels([]string{"nomad", "blocked_evals", "job", "memory"}, float32(v.MemoryMB), labels)
|
||||
}
|
||||
|
||||
for k, v := range stats.BlockedResources.ByClassInDC {
|
||||
labels := []metrics.Label{
|
||||
{Name: "datacenter", Value: k.dc},
|
||||
{Name: "node_class", Value: k.class},
|
||||
{Name: "node_pool", Value: k.nodepool},
|
||||
}
|
||||
metrics.SetGaugeWithLabels([]string{"nomad", "blocked_evals", "cpu"}, float32(v.CPU), labels)
|
||||
metrics.SetGaugeWithLabels([]string{"nomad", "blocked_evals", "memory"}, float32(v.MemoryMB), labels)
|
||||
}
|
||||
case <-stopCh:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// prune is a long lived function that prunes unnecessary objects on a timer.
|
||||
func (b *BlockedEvals) prune(stopCh <-chan struct{}) {
|
||||
ticker := time.NewTicker(pruneInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-stopCh:
|
||||
return
|
||||
case t := <-ticker.C:
|
||||
cutoff := t.UTC().Add(-1 * pruneThreshold)
|
||||
b.pruneUnblockIndexes(cutoff)
|
||||
b.pruneStats(cutoff)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// pruneUnblockIndexes is used to prune any tracked entry that is excessively
|
||||
// old. This protects against unbounded growth of the map.
|
||||
func (b *BlockedEvals) pruneUnblockIndexes(cutoff time.Time) {
|
||||
b.unblockIndexesLock.Lock()
|
||||
defer b.unblockIndexesLock.Unlock()
|
||||
|
||||
for key, u := range b.unblockIndexes {
|
||||
if u.timestamp.Before(cutoff) {
|
||||
delete(b.unblockIndexes, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// pruneStats is used to prune any zero value stats that are excessively old.
|
||||
func (b *BlockedEvals) pruneStats(cutoff time.Time) {
|
||||
b.stats.prune(cutoff)
|
||||
}
|
||||
@@ -1,299 +0,0 @@
|
||||
// Copyright IBM Corp. 2015, 2025
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package nomad
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/hashicorp/nomad/nomad/structs"
|
||||
)
|
||||
|
||||
// BlockedStats returns all the stats about the blocked eval tracker.
|
||||
type BlockedStats struct {
|
||||
// TotalEscaped is the total number of blocked evaluations that have escaped
|
||||
// computed node classes.
|
||||
TotalEscaped int
|
||||
|
||||
// TotalBlocked is the total number of blocked evaluations.
|
||||
TotalBlocked int
|
||||
|
||||
// TotalQuotaLimit is the total number of blocked evaluations that are due
|
||||
// to the quota limit being reached.
|
||||
TotalQuotaLimit int
|
||||
|
||||
// BlockedResources stores the amount of resources requested by blocked
|
||||
// evaluations.
|
||||
BlockedResources *BlockedResourcesStats
|
||||
|
||||
lock sync.RWMutex
|
||||
}
|
||||
|
||||
// classInDC is a coordinate of a specific class in a specific datacenter
|
||||
type classInDC struct {
|
||||
dc string
|
||||
class string
|
||||
nodepool string
|
||||
}
|
||||
|
||||
// NewBlockedStats returns a new BlockedStats.
|
||||
func NewBlockedStats() *BlockedStats {
|
||||
return &BlockedStats{
|
||||
BlockedResources: NewBlockedResourcesStats(),
|
||||
}
|
||||
}
|
||||
|
||||
func (b *BlockedStats) Reset() {
|
||||
b.lock.Lock()
|
||||
defer b.lock.Unlock()
|
||||
|
||||
b.TotalEscaped = 0
|
||||
b.TotalBlocked = 0
|
||||
b.TotalQuotaLimit = 0
|
||||
b.BlockedResources = NewBlockedResourcesStats()
|
||||
}
|
||||
|
||||
// Block updates the stats for the blocked eval tracker with the details of the
|
||||
// evaluation being blocked.
|
||||
func (b *BlockedStats) Block(eval *structs.Evaluation) {
|
||||
b.lock.Lock()
|
||||
defer b.lock.Unlock()
|
||||
|
||||
b.TotalBlocked++
|
||||
resourceStats := generateResourceStats(eval)
|
||||
b.BlockedResources = b.BlockedResources.Add(resourceStats)
|
||||
|
||||
// Track that the evaluation is being added due to reaching the quota limit
|
||||
if eval.QuotaLimitReached != "" {
|
||||
b.TotalQuotaLimit++
|
||||
}
|
||||
|
||||
// If the eval has escaped, meaning computed node classes could not capture
|
||||
// the constraints of the job, we store the eval separately as we have to
|
||||
// unblock it whenever node capacity changes. This is because we don't know
|
||||
// what node class is feasible for the jobs constraints.
|
||||
if eval.EscapedComputedClass {
|
||||
b.TotalEscaped++
|
||||
}
|
||||
}
|
||||
|
||||
// Copy returns a deep clone of the BlockedStats, holding a read lock
|
||||
func (b *BlockedStats) Copy() *BlockedStats {
|
||||
stats := NewBlockedStats()
|
||||
|
||||
b.lock.RLock()
|
||||
defer b.lock.RUnlock()
|
||||
|
||||
stats.TotalEscaped = b.TotalEscaped
|
||||
stats.TotalBlocked = b.TotalBlocked
|
||||
stats.TotalQuotaLimit = b.TotalQuotaLimit
|
||||
stats.BlockedResources = b.BlockedResources.Copy()
|
||||
return stats
|
||||
}
|
||||
|
||||
// Unblock updates the stats for the blocked eval tracker with the details of the
|
||||
// evaluation being unblocked.
|
||||
func (b *BlockedStats) Unblock(eval *structs.Evaluation, escaped bool) {
|
||||
b.lock.Lock()
|
||||
defer b.lock.Unlock()
|
||||
b.unblockImpl(eval, escaped)
|
||||
}
|
||||
|
||||
// UnblockAll updates the stats for a set of evaluations. It will also decrement
|
||||
// TotalEscaped or reset it to zero if negative
|
||||
func (b *BlockedStats) UnblockAll(evals map[*structs.Evaluation]string, escaped int) {
|
||||
b.lock.Lock()
|
||||
defer b.lock.Unlock()
|
||||
|
||||
for eval := range evals {
|
||||
b.unblockImpl(eval, false)
|
||||
}
|
||||
if escaped >= 0 {
|
||||
b.TotalEscaped -= escaped
|
||||
} else {
|
||||
b.TotalEscaped = 0
|
||||
}
|
||||
}
|
||||
|
||||
// unblockImpl expects that b.lock is held by the caller
|
||||
func (b *BlockedStats) unblockImpl(eval *structs.Evaluation, escaped bool) {
|
||||
b.TotalBlocked--
|
||||
|
||||
resourceStats := generateResourceStats(eval)
|
||||
b.BlockedResources = b.BlockedResources.Subtract(resourceStats)
|
||||
|
||||
if eval.QuotaLimitReached != "" {
|
||||
b.TotalQuotaLimit--
|
||||
}
|
||||
if escaped {
|
||||
b.TotalEscaped--
|
||||
}
|
||||
}
|
||||
|
||||
func (b *BlockedStats) decrementEscaped() {
|
||||
b.lock.Lock()
|
||||
defer b.lock.Unlock()
|
||||
b.TotalEscaped--
|
||||
}
|
||||
|
||||
// prune deletes any key zero metric values older than the cutoff.
|
||||
func (b *BlockedStats) prune(cutoff time.Time) {
|
||||
b.lock.Lock()
|
||||
defer b.lock.Unlock()
|
||||
|
||||
shouldPrune := func(s BlockedResourcesSummary) bool {
|
||||
return s.Timestamp.Before(cutoff) && s.IsZero()
|
||||
}
|
||||
|
||||
for k, v := range b.BlockedResources.ByJob {
|
||||
if shouldPrune(v) {
|
||||
delete(b.BlockedResources.ByJob, k)
|
||||
}
|
||||
}
|
||||
|
||||
for k, v := range b.BlockedResources.ByClassInDC {
|
||||
if shouldPrune(v) {
|
||||
delete(b.BlockedResources.ByClassInDC, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// generateResourceStats returns a summary of the resources requested by the
|
||||
// input evaluation.
|
||||
func generateResourceStats(eval *structs.Evaluation) *BlockedResourcesStats {
|
||||
dcs := make(map[string]struct{})
|
||||
classes := make(map[string]struct{})
|
||||
nodepools := make(map[string]struct{})
|
||||
|
||||
resources := BlockedResourcesSummary{
|
||||
Timestamp: time.Now().UTC(),
|
||||
}
|
||||
|
||||
for _, allocMetrics := range eval.FailedTGAllocs {
|
||||
for dc := range allocMetrics.NodesAvailable {
|
||||
dcs[dc] = struct{}{}
|
||||
}
|
||||
for class := range allocMetrics.ClassExhausted {
|
||||
classes[class] = struct{}{}
|
||||
}
|
||||
|
||||
nodepools[allocMetrics.NodePool] = struct{}{}
|
||||
|
||||
if len(allocMetrics.ClassExhausted) == 0 {
|
||||
// some evaluations have no class
|
||||
classes[""] = struct{}{}
|
||||
}
|
||||
for _, r := range allocMetrics.ResourcesExhausted {
|
||||
resources.CPU += r.CPU
|
||||
resources.MemoryMB += r.MemoryMB
|
||||
}
|
||||
}
|
||||
|
||||
byJob := make(map[structs.NamespacedID]BlockedResourcesSummary)
|
||||
nsID := structs.NewNamespacedID(eval.JobID, eval.Namespace)
|
||||
byJob[nsID] = resources
|
||||
|
||||
byClassInDC := make(map[classInDC]BlockedResourcesSummary)
|
||||
for nodepool := range nodepools {
|
||||
for dc := range dcs {
|
||||
for class := range classes {
|
||||
k := classInDC{dc: dc, class: class, nodepool: nodepool}
|
||||
byClassInDC[k] = resources
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return &BlockedResourcesStats{
|
||||
ByJob: byJob,
|
||||
ByClassInDC: byClassInDC,
|
||||
}
|
||||
}
|
||||
|
||||
// BlockedResourcesStats stores resources requested by blocked evaluations,
|
||||
// tracked both by job and by node.
|
||||
type BlockedResourcesStats struct {
|
||||
ByJob map[structs.NamespacedID]BlockedResourcesSummary
|
||||
ByClassInDC map[classInDC]BlockedResourcesSummary
|
||||
}
|
||||
|
||||
// NewBlockedResourcesStats returns a new BlockedResourcesStats.
|
||||
func NewBlockedResourcesStats() *BlockedResourcesStats {
|
||||
return &BlockedResourcesStats{
|
||||
ByJob: make(map[structs.NamespacedID]BlockedResourcesSummary),
|
||||
ByClassInDC: make(map[classInDC]BlockedResourcesSummary),
|
||||
}
|
||||
}
|
||||
|
||||
// Copy returns a deep copy of the blocked resource stats.
|
||||
func (b *BlockedResourcesStats) Copy() *BlockedResourcesStats {
|
||||
result := NewBlockedResourcesStats()
|
||||
|
||||
for k, v := range b.ByJob {
|
||||
result.ByJob[k] = v // value copy
|
||||
}
|
||||
|
||||
for k, v := range b.ByClassInDC {
|
||||
result.ByClassInDC[k] = v // value copy
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// Add returns a new BlockedResourcesStats with the values set to the current
|
||||
// resource values plus the input.
|
||||
func (b *BlockedResourcesStats) Add(a *BlockedResourcesStats) *BlockedResourcesStats {
|
||||
|
||||
for k, v := range a.ByJob {
|
||||
b.ByJob[k] = b.ByJob[k].Add(v)
|
||||
}
|
||||
for k, v := range a.ByClassInDC {
|
||||
b.ByClassInDC[k] = b.ByClassInDC[k].Add(v)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// Subtract returns a new BlockedResourcesStats with the values set to the
|
||||
// current resource values minus the input.
|
||||
func (b *BlockedResourcesStats) Subtract(a *BlockedResourcesStats) *BlockedResourcesStats {
|
||||
|
||||
for k, v := range a.ByJob {
|
||||
b.ByJob[k] = b.ByJob[k].Subtract(v)
|
||||
}
|
||||
for k, v := range a.ByClassInDC {
|
||||
b.ByClassInDC[k] = b.ByClassInDC[k].Subtract(v)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// BlockedResourcesSummary stores resource values for blocked evals.
|
||||
type BlockedResourcesSummary struct {
|
||||
Timestamp time.Time
|
||||
CPU int
|
||||
MemoryMB int
|
||||
}
|
||||
|
||||
// Add returns a new BlockedResourcesSummary with each resource set to the
|
||||
// current value plus the input.
|
||||
func (b BlockedResourcesSummary) Add(a BlockedResourcesSummary) BlockedResourcesSummary {
|
||||
return BlockedResourcesSummary{
|
||||
Timestamp: a.Timestamp,
|
||||
CPU: b.CPU + a.CPU,
|
||||
MemoryMB: b.MemoryMB + a.MemoryMB,
|
||||
}
|
||||
}
|
||||
|
||||
// Subtract returns a new BlockedResourcesSummary with each resource set to the
|
||||
// current value minus the input.
|
||||
func (b BlockedResourcesSummary) Subtract(a BlockedResourcesSummary) BlockedResourcesSummary {
|
||||
return BlockedResourcesSummary{
|
||||
Timestamp: a.Timestamp,
|
||||
CPU: b.CPU - a.CPU,
|
||||
MemoryMB: b.MemoryMB - a.MemoryMB,
|
||||
}
|
||||
}
|
||||
|
||||
// IsZero returns true if all resource values are zero.
|
||||
func (b BlockedResourcesSummary) IsZero() bool {
|
||||
return b.CPU == 0 && b.MemoryMB == 0
|
||||
}
|
||||
@@ -1,471 +0,0 @@
|
||||
// Copyright IBM Corp. 2015, 2025
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package nomad
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"reflect"
|
||||
"testing"
|
||||
"testing/quick"
|
||||
"time"
|
||||
|
||||
"github.com/hashicorp/nomad/ci"
|
||||
"github.com/hashicorp/nomad/nomad/mock"
|
||||
"github.com/hashicorp/nomad/nomad/structs"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func now(year int) time.Time {
|
||||
return time.Date(2000+year, 1, 2, 3, 4, 5, 6, time.UTC)
|
||||
}
|
||||
|
||||
func TestBlockedResourceSummary_Add(t *testing.T) {
|
||||
now1 := now(1)
|
||||
now2 := now(2)
|
||||
a := BlockedResourcesSummary{
|
||||
Timestamp: now1,
|
||||
CPU: 600,
|
||||
MemoryMB: 256,
|
||||
}
|
||||
|
||||
b := BlockedResourcesSummary{
|
||||
Timestamp: now2,
|
||||
CPU: 250,
|
||||
MemoryMB: 128,
|
||||
}
|
||||
|
||||
result := a.Add(b)
|
||||
|
||||
// a not modified
|
||||
require.Equal(t, 600, a.CPU)
|
||||
require.Equal(t, 256, a.MemoryMB)
|
||||
require.Equal(t, now1, a.Timestamp)
|
||||
|
||||
// b not modified
|
||||
require.Equal(t, 250, b.CPU)
|
||||
require.Equal(t, 128, b.MemoryMB)
|
||||
require.Equal(t, now2, b.Timestamp)
|
||||
|
||||
// result is a + b, using timestamp from b
|
||||
require.Equal(t, 850, result.CPU)
|
||||
require.Equal(t, 384, result.MemoryMB)
|
||||
require.Equal(t, now2, result.Timestamp)
|
||||
}
|
||||
|
||||
func TestBlockedResourceSummary_Add_nil(t *testing.T) {
|
||||
now1 := now(1)
|
||||
b := BlockedResourcesSummary{
|
||||
Timestamp: now1,
|
||||
CPU: 250,
|
||||
MemoryMB: 128,
|
||||
}
|
||||
|
||||
// zero + b == b
|
||||
result := (BlockedResourcesSummary{}).Add(b)
|
||||
require.Equal(t, now1, result.Timestamp)
|
||||
require.Equal(t, 250, result.CPU)
|
||||
require.Equal(t, 128, result.MemoryMB)
|
||||
}
|
||||
|
||||
func TestBlockedResourceSummary_Subtract(t *testing.T) {
|
||||
now1 := now(1)
|
||||
now2 := now(2)
|
||||
a := BlockedResourcesSummary{
|
||||
Timestamp: now1,
|
||||
CPU: 600,
|
||||
MemoryMB: 256,
|
||||
}
|
||||
|
||||
b := BlockedResourcesSummary{
|
||||
Timestamp: now2,
|
||||
CPU: 250,
|
||||
MemoryMB: 120,
|
||||
}
|
||||
|
||||
result := a.Subtract(b)
|
||||
|
||||
// a not modified
|
||||
require.Equal(t, 600, a.CPU)
|
||||
require.Equal(t, 256, a.MemoryMB)
|
||||
require.Equal(t, now1, a.Timestamp)
|
||||
|
||||
// b not modified
|
||||
require.Equal(t, 250, b.CPU)
|
||||
require.Equal(t, 120, b.MemoryMB)
|
||||
require.Equal(t, now2, b.Timestamp)
|
||||
|
||||
// result is a + b, using timestamp from b
|
||||
require.Equal(t, 350, result.CPU)
|
||||
require.Equal(t, 136, result.MemoryMB)
|
||||
require.Equal(t, now2, result.Timestamp)
|
||||
}
|
||||
|
||||
func TestBlockedResourceSummary_IsZero(t *testing.T) {
|
||||
now1 := now(1)
|
||||
|
||||
// cpu and mem zero, timestamp is ignored
|
||||
require.True(t, (&BlockedResourcesSummary{
|
||||
Timestamp: now1,
|
||||
CPU: 0,
|
||||
MemoryMB: 0,
|
||||
}).IsZero())
|
||||
|
||||
// cpu non-zero
|
||||
require.False(t, (&BlockedResourcesSummary{
|
||||
Timestamp: now1,
|
||||
CPU: 1,
|
||||
MemoryMB: 0,
|
||||
}).IsZero())
|
||||
|
||||
// mem non-zero
|
||||
require.False(t, (&BlockedResourcesSummary{
|
||||
Timestamp: now1,
|
||||
CPU: 0,
|
||||
MemoryMB: 1,
|
||||
}).IsZero())
|
||||
}
|
||||
|
||||
func TestBlockedResourceStats_New(t *testing.T) {
|
||||
a := NewBlockedResourcesStats()
|
||||
require.NotNil(t, a.ByJob)
|
||||
require.Empty(t, a.ByJob)
|
||||
require.NotNil(t, a.ByClassInDC)
|
||||
require.Empty(t, a.ByClassInDC)
|
||||
}
|
||||
|
||||
var (
|
||||
id1 = structs.NamespacedID{
|
||||
ID: "1",
|
||||
Namespace: "one",
|
||||
}
|
||||
|
||||
id2 = structs.NamespacedID{
|
||||
ID: "2",
|
||||
Namespace: "two",
|
||||
}
|
||||
|
||||
node1 = classInDC{
|
||||
dc: "dc1",
|
||||
class: "alpha",
|
||||
nodepool: "default",
|
||||
}
|
||||
|
||||
node2 = classInDC{
|
||||
dc: "dc1",
|
||||
class: "beta",
|
||||
nodepool: "dev",
|
||||
}
|
||||
|
||||
node3 = classInDC{
|
||||
dc: "dc1",
|
||||
class: "", // not set
|
||||
}
|
||||
)
|
||||
|
||||
func TestBlockedResourceStats_Copy(t *testing.T) {
|
||||
now1 := now(1)
|
||||
now2 := now(2)
|
||||
|
||||
a := NewBlockedResourcesStats()
|
||||
a.ByJob = map[structs.NamespacedID]BlockedResourcesSummary{
|
||||
id1: {
|
||||
Timestamp: now1,
|
||||
CPU: 100,
|
||||
MemoryMB: 256,
|
||||
},
|
||||
}
|
||||
a.ByClassInDC = map[classInDC]BlockedResourcesSummary{
|
||||
node1: {
|
||||
Timestamp: now1,
|
||||
CPU: 300,
|
||||
MemoryMB: 333,
|
||||
},
|
||||
}
|
||||
|
||||
c := a.Copy()
|
||||
c.ByJob[id1] = BlockedResourcesSummary{
|
||||
Timestamp: now2,
|
||||
CPU: 888,
|
||||
MemoryMB: 888,
|
||||
}
|
||||
c.ByClassInDC[node1] = BlockedResourcesSummary{
|
||||
Timestamp: now2,
|
||||
CPU: 999,
|
||||
MemoryMB: 999,
|
||||
}
|
||||
|
||||
// underlying data should have been deep copied
|
||||
require.Equal(t, 100, a.ByJob[id1].CPU)
|
||||
require.Equal(t, 300, a.ByClassInDC[node1].CPU)
|
||||
}
|
||||
|
||||
func TestBlockedResourcesStats_Add(t *testing.T) {
|
||||
a := NewBlockedResourcesStats()
|
||||
a.ByJob = map[structs.NamespacedID]BlockedResourcesSummary{
|
||||
id1: {Timestamp: now(1), CPU: 111, MemoryMB: 222},
|
||||
}
|
||||
a.ByClassInDC = map[classInDC]BlockedResourcesSummary{
|
||||
node1: {Timestamp: now(2), CPU: 333, MemoryMB: 444},
|
||||
}
|
||||
|
||||
b := NewBlockedResourcesStats()
|
||||
b.ByJob = map[structs.NamespacedID]BlockedResourcesSummary{
|
||||
id1: {Timestamp: now(3), CPU: 200, MemoryMB: 300},
|
||||
id2: {Timestamp: now(4), CPU: 400, MemoryMB: 500},
|
||||
}
|
||||
b.ByClassInDC = map[classInDC]BlockedResourcesSummary{
|
||||
node1: {Timestamp: now(5), CPU: 600, MemoryMB: 700},
|
||||
node2: {Timestamp: now(6), CPU: 800, MemoryMB: 900},
|
||||
}
|
||||
|
||||
t.Run("a add b", func(t *testing.T) {
|
||||
result := a.Copy().Add(b)
|
||||
|
||||
require.Equal(t, map[structs.NamespacedID]BlockedResourcesSummary{
|
||||
id1: {Timestamp: now(3), CPU: 311, MemoryMB: 522},
|
||||
id2: {Timestamp: now(4), CPU: 400, MemoryMB: 500},
|
||||
}, result.ByJob)
|
||||
|
||||
require.Equal(t, map[classInDC]BlockedResourcesSummary{
|
||||
node1: {Timestamp: now(5), CPU: 933, MemoryMB: 1144},
|
||||
node2: {Timestamp: now(6), CPU: 800, MemoryMB: 900},
|
||||
}, result.ByClassInDC)
|
||||
})
|
||||
|
||||
// make sure we handle zeros in both directions
|
||||
// and timestamps originate from rhs
|
||||
t.Run("b add a", func(t *testing.T) {
|
||||
result := b.Copy().Add(a)
|
||||
require.Equal(t, map[structs.NamespacedID]BlockedResourcesSummary{
|
||||
id1: {Timestamp: now(1), CPU: 311, MemoryMB: 522},
|
||||
id2: {Timestamp: now(4), CPU: 400, MemoryMB: 500},
|
||||
}, result.ByJob)
|
||||
|
||||
require.Equal(t, map[classInDC]BlockedResourcesSummary{
|
||||
node1: {Timestamp: now(2), CPU: 933, MemoryMB: 1144},
|
||||
node2: {Timestamp: now(6), CPU: 800, MemoryMB: 900},
|
||||
}, result.ByClassInDC)
|
||||
})
|
||||
}
|
||||
|
||||
func TestBlockedResourcesStats_Add_NoClass(t *testing.T) {
|
||||
a := NewBlockedResourcesStats()
|
||||
a.ByClassInDC = map[classInDC]BlockedResourcesSummary{
|
||||
node3: {Timestamp: now(1), CPU: 111, MemoryMB: 1111},
|
||||
}
|
||||
result := a.Add(a)
|
||||
require.Equal(t, map[classInDC]BlockedResourcesSummary{
|
||||
node3: {Timestamp: now(1), CPU: 222, MemoryMB: 2222},
|
||||
}, result.ByClassInDC)
|
||||
}
|
||||
|
||||
func TestBlockedResourcesStats_Subtract(t *testing.T) {
|
||||
a := NewBlockedResourcesStats()
|
||||
a.ByJob = map[structs.NamespacedID]BlockedResourcesSummary{
|
||||
id1: {Timestamp: now(1), CPU: 100, MemoryMB: 100},
|
||||
id2: {Timestamp: now(2), CPU: 200, MemoryMB: 200},
|
||||
}
|
||||
a.ByClassInDC = map[classInDC]BlockedResourcesSummary{
|
||||
node1: {Timestamp: now(3), CPU: 300, MemoryMB: 300},
|
||||
node2: {Timestamp: now(4), CPU: 400, MemoryMB: 400},
|
||||
}
|
||||
|
||||
b := NewBlockedResourcesStats()
|
||||
b.ByJob = map[structs.NamespacedID]BlockedResourcesSummary{
|
||||
id1: {Timestamp: now(5), CPU: 10, MemoryMB: 11},
|
||||
id2: {Timestamp: now(6), CPU: 12, MemoryMB: 13},
|
||||
}
|
||||
b.ByClassInDC = map[classInDC]BlockedResourcesSummary{
|
||||
node1: {Timestamp: now(7), CPU: 14, MemoryMB: 15},
|
||||
node2: {Timestamp: now(8), CPU: 16, MemoryMB: 17},
|
||||
}
|
||||
|
||||
result := a.Subtract(b)
|
||||
|
||||
// id1
|
||||
require.Equal(t, now(5), result.ByJob[id1].Timestamp)
|
||||
require.Equal(t, 90, result.ByJob[id1].CPU)
|
||||
require.Equal(t, 89, result.ByJob[id1].MemoryMB)
|
||||
|
||||
// id2
|
||||
require.Equal(t, now(6), result.ByJob[id2].Timestamp)
|
||||
require.Equal(t, 188, result.ByJob[id2].CPU)
|
||||
require.Equal(t, 187, result.ByJob[id2].MemoryMB)
|
||||
|
||||
// node1
|
||||
require.Equal(t, now(7), result.ByClassInDC[node1].Timestamp)
|
||||
require.Equal(t, 286, result.ByClassInDC[node1].CPU)
|
||||
require.Equal(t, 285, result.ByClassInDC[node1].MemoryMB)
|
||||
|
||||
// node2
|
||||
require.Equal(t, now(8), result.ByClassInDC[node2].Timestamp)
|
||||
require.Equal(t, 384, result.ByClassInDC[node2].CPU)
|
||||
require.Equal(t, 383, result.ByClassInDC[node2].MemoryMB)
|
||||
}
|
||||
|
||||
// testBlockedEvalsRandomBlockedEval wraps an eval that is randomly generated.
|
||||
type testBlockedEvalsRandomBlockedEval struct {
|
||||
eval *structs.Evaluation
|
||||
}
|
||||
|
||||
// Generate returns a random eval.
|
||||
func (t testBlockedEvalsRandomBlockedEval) Generate(rand *rand.Rand, _ int) reflect.Value {
|
||||
resourceTypes := []string{"cpu", "memory"}
|
||||
|
||||
// Start with a mock eval.
|
||||
e := mock.BlockedEval()
|
||||
|
||||
// Get how many task groups, datacenters and node classes to generate.
|
||||
// Add 1 to avoid 0.
|
||||
jobCount := rand.Intn(3) + 1
|
||||
tgCount := rand.Intn(10) + 1
|
||||
dcCount := rand.Intn(3) + 1
|
||||
nodeClassCount := rand.Intn(3) + 1
|
||||
nodePoolName := fmt.Sprintf("node-pool-%d", rand.Intn(3)+1)
|
||||
|
||||
failedTGAllocs := map[string]*structs.AllocMetric{}
|
||||
|
||||
e.JobID = fmt.Sprintf("job-%d", jobCount)
|
||||
for tg := 1; tg <= tgCount; tg++ {
|
||||
tgName := fmt.Sprintf("group-%d", tg)
|
||||
|
||||
// Get which resource type to use for this task group.
|
||||
// Nomad stops at the first dimension that is exhausted, so only 1 is
|
||||
// added per task group.
|
||||
i := rand.Int() % len(resourceTypes)
|
||||
resourceType := resourceTypes[i]
|
||||
|
||||
failedTGAllocs[tgName] = &structs.AllocMetric{
|
||||
DimensionExhausted: map[string]int{
|
||||
resourceType: 1,
|
||||
},
|
||||
NodesAvailable: map[string]int{},
|
||||
ClassExhausted: map[string]int{},
|
||||
NodePool: nodePoolName,
|
||||
}
|
||||
|
||||
for dc := 1; dc <= dcCount; dc++ {
|
||||
dcName := fmt.Sprintf("dc%d", dc)
|
||||
failedTGAllocs[tgName].NodesAvailable[dcName] = 1
|
||||
}
|
||||
|
||||
for nc := 1; nc <= nodeClassCount; nc++ {
|
||||
nodeClassName := fmt.Sprintf("node-class-%d", nc)
|
||||
failedTGAllocs[tgName].ClassExhausted[nodeClassName] = 1
|
||||
}
|
||||
|
||||
// Generate resources for each task.
|
||||
taskCount := rand.Intn(5) + 1
|
||||
resourcesExhausted := map[string]*structs.Resources{}
|
||||
|
||||
for t := 1; t <= taskCount; t++ {
|
||||
task := fmt.Sprintf("task-%d", t)
|
||||
resourcesExhausted[task] = &structs.Resources{}
|
||||
|
||||
resourceAmount := rand.Intn(1000)
|
||||
switch resourceType {
|
||||
case "cpu":
|
||||
resourcesExhausted[task].CPU = resourceAmount
|
||||
case "memory":
|
||||
resourcesExhausted[task].MemoryMB = resourceAmount
|
||||
}
|
||||
}
|
||||
failedTGAllocs[tgName].ResourcesExhausted = resourcesExhausted
|
||||
}
|
||||
e.FailedTGAllocs = failedTGAllocs
|
||||
t.eval = e
|
||||
return reflect.ValueOf(t)
|
||||
}
|
||||
|
||||
// clearTimestampFromBlockedResourceStats set timestamp metrics to zero to
|
||||
// avoid invalid comparisons.
|
||||
func clearTimestampFromBlockedResourceStats(b *BlockedResourcesStats) {
|
||||
for k, v := range b.ByJob {
|
||||
v.Timestamp = time.Time{}
|
||||
b.ByJob[k] = v
|
||||
}
|
||||
for k, v := range b.ByClassInDC {
|
||||
v.Timestamp = time.Time{}
|
||||
b.ByClassInDC[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
// TestBlockedEvalsStats_BlockedResources generates random evals and processes
|
||||
// them using the expected code paths and a manual check of the expeceted result.
|
||||
func TestBlockedEvalsStats_BlockedResources(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
blocked, _ := testBlockedEvals(t)
|
||||
|
||||
// evalHistory stores all evals generated during the test.
|
||||
var evalHistory []*structs.Evaluation
|
||||
|
||||
// blockedEvals keeps track if evals are blocked or unblocked.
|
||||
blockedEvals := map[string]bool{}
|
||||
|
||||
// blockAndUntrack processes the generated evals in order using a
|
||||
// BlockedEvals instance.
|
||||
blockAndUntrack := func(testEval testBlockedEvalsRandomBlockedEval, block bool, unblockIdx uint16) *BlockedResourcesStats {
|
||||
if block || len(evalHistory) == 0 {
|
||||
<-blocked.Block(testEval.eval)
|
||||
} else {
|
||||
i := int(unblockIdx) % len(evalHistory)
|
||||
eval := evalHistory[i]
|
||||
<-blocked.Untrack(eval.JobID, eval.Namespace)
|
||||
}
|
||||
// Remove zero stats from unblocked evals.
|
||||
blocked.pruneStats(time.Now().UTC())
|
||||
|
||||
result := blocked.Stats().BlockedResources
|
||||
clearTimestampFromBlockedResourceStats(result)
|
||||
return result
|
||||
}
|
||||
|
||||
// manualCount processes only the blocked evals and generate a
|
||||
// BlockedResourcesStats result directly from the eval history.
|
||||
manualCount := func(testEval testBlockedEvalsRandomBlockedEval, block bool, unblockIdx uint16) *BlockedResourcesStats {
|
||||
|
||||
if block || len(evalHistory) == 0 {
|
||||
|
||||
evalHistory = append(evalHistory, testEval.eval)
|
||||
|
||||
// Find and unblock evals for the same job.
|
||||
for _, e := range evalHistory {
|
||||
if e.Namespace == testEval.eval.Namespace && e.JobID == testEval.eval.JobID {
|
||||
blockedEvals[e.ID] = false
|
||||
}
|
||||
}
|
||||
|
||||
blockedEvals[testEval.eval.ID] = true
|
||||
} else {
|
||||
i := int(unblockIdx) % len(evalHistory)
|
||||
eval := evalHistory[i]
|
||||
counts := 0
|
||||
|
||||
// Find and unlock all evals for this job.
|
||||
for _, e := range evalHistory {
|
||||
if e.Namespace == eval.Namespace && e.JobID == eval.JobID {
|
||||
counts++
|
||||
blockedEvals[e.ID] = false
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
result := NewBlockedResourcesStats()
|
||||
for _, e := range evalHistory {
|
||||
if !blockedEvals[e.ID] {
|
||||
continue
|
||||
}
|
||||
result = result.Add(generateResourceStats(e))
|
||||
}
|
||||
clearTimestampFromBlockedResourceStats(result)
|
||||
return result
|
||||
}
|
||||
|
||||
err := quick.CheckEqual(blockAndUntrack, manualCount, nil)
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
// Copyright IBM Corp. 2015, 2025
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package nomad
|
||||
|
||||
import "github.com/hashicorp/nomad/nomad/structs"
|
||||
|
||||
// systemEvals are handled specially, each job may have a blocked eval on each node
|
||||
type systemEvals struct {
|
||||
// byJob maps a jobID to a nodeID to that job's single blocked evalID on that node
|
||||
byJob map[structs.NamespacedID]map[string]string
|
||||
|
||||
// byNode maps a nodeID to a set of evalIDs
|
||||
byNode map[string]map[string]bool
|
||||
|
||||
// evals maps evalIDs to an eval and token
|
||||
evals map[string]*wrappedEval
|
||||
}
|
||||
|
||||
func newSystemEvals() *systemEvals {
|
||||
return &systemEvals{
|
||||
evals: map[string]*wrappedEval{},
|
||||
byJob: map[structs.NamespacedID]map[string]string{},
|
||||
byNode: map[string]map[string]bool{},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *systemEvals) Add(eval *structs.Evaluation, token string) {
|
||||
// store the eval by node id
|
||||
if _, ok := s.byNode[eval.NodeID]; !ok {
|
||||
s.byNode[eval.NodeID] = make(map[string]bool)
|
||||
}
|
||||
|
||||
s.byNode[eval.NodeID][eval.ID] = true
|
||||
s.evals[eval.ID] = &wrappedEval{eval: eval, token: token}
|
||||
|
||||
// link the job to the node for cleanup
|
||||
jobID := structs.NewNamespacedID(eval.JobID, eval.Namespace)
|
||||
if _, ok := s.byJob[jobID]; !ok {
|
||||
s.byJob[jobID] = make(map[string]string)
|
||||
}
|
||||
|
||||
// if we're displacing the old blocked id for this job+node, delete it first
|
||||
if prevID, ok := s.byJob[jobID][eval.NodeID]; ok {
|
||||
prev, _ := s.Get(prevID)
|
||||
s.Remove(prev.eval)
|
||||
}
|
||||
|
||||
// set this eval as the new eval for this job on this node
|
||||
s.byJob[jobID][eval.NodeID] = eval.ID
|
||||
}
|
||||
|
||||
func (s *systemEvals) Get(evalID string) (*wrappedEval, bool) {
|
||||
w, ok := s.evals[evalID]
|
||||
return w, ok
|
||||
}
|
||||
|
||||
func (s *systemEvals) Remove(eval *structs.Evaluation) {
|
||||
// delete the job index if this eval is the currently listed blocked eval
|
||||
jobID := structs.NewNamespacedID(eval.JobID, eval.Namespace)
|
||||
e, ok := s.byJob[jobID][eval.NodeID]
|
||||
if ok && e == eval.ID {
|
||||
delete(s.byJob[jobID], eval.NodeID)
|
||||
}
|
||||
|
||||
// delete this eval from the node index, and then the map for this node if empty
|
||||
delete(s.byNode[eval.NodeID], eval.ID)
|
||||
if len(s.byNode[eval.NodeID]) == 0 {
|
||||
delete(s.byNode, eval.NodeID)
|
||||
}
|
||||
|
||||
// delete the eval itself
|
||||
delete(s.evals, eval.ID)
|
||||
}
|
||||
|
||||
func (s *systemEvals) NodeEvals(nodeID string) (map[*structs.Evaluation]string, bool) {
|
||||
out := map[*structs.Evaluation]string{}
|
||||
for eID := range s.byNode[nodeID] {
|
||||
if w, ok := s.Get(eID); ok {
|
||||
out[w.eval] = w.token
|
||||
}
|
||||
}
|
||||
|
||||
ok := len(out) > 0
|
||||
return out, ok
|
||||
}
|
||||
|
||||
func (s *systemEvals) JobEvals(jobID structs.NamespacedID) ([]*structs.Evaluation, bool) {
|
||||
out := []*structs.Evaluation{}
|
||||
_, ok := s.byJob[jobID]
|
||||
for _, eID := range s.byJob[jobID] {
|
||||
if e, ok := s.Get(eID); ok {
|
||||
out = append(out, e.eval)
|
||||
}
|
||||
}
|
||||
return out, ok
|
||||
}
|
||||
@@ -1,811 +0,0 @@
|
||||
// Copyright IBM Corp. 2015, 2025
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package nomad
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/hashicorp/nomad/ci"
|
||||
"github.com/hashicorp/nomad/helper/testlog"
|
||||
"github.com/hashicorp/nomad/nomad/mock"
|
||||
"github.com/hashicorp/nomad/nomad/structs"
|
||||
"github.com/shoenig/test/must"
|
||||
"github.com/shoenig/test/wait"
|
||||
)
|
||||
|
||||
// Stats is used to query the state of the blocked eval tracker. This is only
|
||||
// ever called in tests.
|
||||
func (b *BlockedEvals) Stats() *BlockedStats {
|
||||
return b.stats.Copy()
|
||||
}
|
||||
|
||||
func testBlockedEvals(t *testing.T) (*BlockedEvals, *EvalBroker) {
|
||||
broker := testBroker(t, 0)
|
||||
broker.SetEnabled(true)
|
||||
blocked := NewBlockedEvals(broker, testlog.HCLogger(t))
|
||||
blocked.SetEnabled(true)
|
||||
return blocked, broker
|
||||
}
|
||||
|
||||
func TestBlockedEvals_Block_Disabled(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
blocked, _ := testBlockedEvals(t)
|
||||
blocked.SetEnabled(false)
|
||||
|
||||
// Create an escaped eval and add it to the blocked tracker.
|
||||
e := mock.BlockedEval()
|
||||
e.EscapedComputedClass = true
|
||||
<-blocked.Block(e)
|
||||
|
||||
// Verify block did nothing.
|
||||
stats := blocked.Stats()
|
||||
must.Eq(t, 0, stats.TotalBlocked)
|
||||
must.Eq(t, 0, stats.TotalEscaped)
|
||||
must.MapLen(t, 0, stats.BlockedResources.ByJob)
|
||||
}
|
||||
|
||||
func TestBlockedEvals_Block_SameJob(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
blocked, _ := testBlockedEvals(t)
|
||||
|
||||
// Create two blocked evals and add them to the blocked tracker.
|
||||
e := mock.BlockedEval()
|
||||
e2 := mock.BlockedEval()
|
||||
e2.JobID = e.JobID
|
||||
<-blocked.Block(e)
|
||||
<-blocked.Block(e2)
|
||||
|
||||
// Verify block didn't track duplicate.
|
||||
stats := blocked.Stats()
|
||||
must.Eq(t, 1, stats.TotalBlocked)
|
||||
must.Eq(t, 0, stats.TotalEscaped)
|
||||
must.MapLen(t, 1, stats.BlockedResources.ByJob)
|
||||
}
|
||||
|
||||
func TestBlockedEvals_Block_Quota(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
|
||||
blocked, _ := testBlockedEvals(t)
|
||||
|
||||
// Create a blocked eval on quota.
|
||||
e := mock.BlockedEval()
|
||||
e.QuotaLimitReached = "foo"
|
||||
<-blocked.Block(e)
|
||||
|
||||
// Verify block did track eval.
|
||||
stats := blocked.Stats()
|
||||
must.Eq(t, 1, stats.TotalBlocked)
|
||||
must.Eq(t, 0, stats.TotalEscaped)
|
||||
must.Eq(t, 1, stats.TotalQuotaLimit)
|
||||
}
|
||||
|
||||
func TestBlockedEvals_Block_PriorUnblocks(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
blocked, _ := testBlockedEvals(t)
|
||||
|
||||
// Do unblocks prior to blocking.
|
||||
blocked.Unblock("v1:123", 1000)
|
||||
blocked.Unblock("v1:123", 1001)
|
||||
|
||||
// Create blocked eval with two classes ineligible.
|
||||
e := mock.BlockedEval()
|
||||
e.ClassEligibility = map[string]bool{"v1:123": false, "v1:456": false}
|
||||
e.SnapshotIndex = 999
|
||||
<-blocked.Block(e)
|
||||
|
||||
// Verify block did track eval.
|
||||
stats := blocked.Stats()
|
||||
must.Eq(t, 1, stats.TotalBlocked)
|
||||
must.Eq(t, 0, stats.TotalEscaped)
|
||||
must.MapLen(t, 1, stats.BlockedResources.ByJob)
|
||||
}
|
||||
|
||||
func TestBlockedEvals_GetDuplicates(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
blocked, _ := testBlockedEvals(t)
|
||||
|
||||
// Create duplicate blocked evals and add them to the blocked tracker.
|
||||
e := mock.BlockedEval()
|
||||
e.CreateIndex = 100
|
||||
e2 := mock.BlockedEval()
|
||||
e2.JobID = e.JobID
|
||||
e2.CreateIndex = 101
|
||||
e3 := mock.BlockedEval()
|
||||
e3.JobID = e.JobID
|
||||
e3.CreateIndex = 102
|
||||
e4 := mock.BlockedEval()
|
||||
e4.JobID = e.JobID
|
||||
e4.CreateIndex = 100
|
||||
<-blocked.Block(e)
|
||||
<-blocked.Block(e2)
|
||||
|
||||
// Verify stats such that we are only tracking one.
|
||||
stats := blocked.Stats()
|
||||
must.Eq(t, 1, stats.TotalBlocked)
|
||||
must.Eq(t, 0, stats.TotalEscaped)
|
||||
must.MapLen(t, 1, stats.BlockedResources.ByJob)
|
||||
|
||||
// Get the duplicates.
|
||||
out := blocked.GetDuplicates(0)
|
||||
must.Len(t, 1, out)
|
||||
must.Eq(t, e, out[0])
|
||||
|
||||
// Call block again after a small sleep.
|
||||
go func() {
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
<-blocked.Block(e3)
|
||||
}()
|
||||
|
||||
// Get the duplicates.
|
||||
out = blocked.GetDuplicates(1 * time.Second)
|
||||
must.Len(t, 1, out)
|
||||
must.Eq(t, e2, out[0])
|
||||
|
||||
// Verify stats such that we are only tracking one.
|
||||
stats = blocked.Stats()
|
||||
must.Eq(t, 1, stats.TotalBlocked)
|
||||
must.Eq(t, 0, stats.TotalEscaped)
|
||||
must.MapLen(t, 1, stats.BlockedResources.ByJob)
|
||||
|
||||
// Add an older evaluation and assert it gets cancelled.
|
||||
<-blocked.Block(e4)
|
||||
out = blocked.GetDuplicates(0)
|
||||
must.Len(t, 1, out)
|
||||
must.Eq(t, e4, out[0])
|
||||
|
||||
// Verify stats such that we are only tracking one.
|
||||
stats = blocked.Stats()
|
||||
must.Eq(t, 1, stats.TotalBlocked)
|
||||
must.Eq(t, 0, stats.TotalEscaped)
|
||||
must.MapLen(t, 1, stats.BlockedResources.ByJob)
|
||||
}
|
||||
|
||||
func TestBlockedEvals_UnblockEscaped(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
|
||||
blocked, broker := testBlockedEvals(t)
|
||||
|
||||
// Create an escaped eval and add it to the blocked tracker.
|
||||
e := mock.BlockedEval()
|
||||
e.Status = structs.EvalStatusBlocked
|
||||
e.EscapedComputedClass = true
|
||||
<-blocked.Block(e)
|
||||
|
||||
// Verify block caused the eval to be tracked
|
||||
stats := blocked.Stats()
|
||||
must.Eq(t, 1, stats.TotalBlocked)
|
||||
must.Eq(t, 1, stats.TotalEscaped)
|
||||
must.MapLen(t, 1, stats.BlockedResources.ByJob)
|
||||
|
||||
<-blocked.Unblock("v1:123", 1000)
|
||||
requireBlockedEvalsEnqueued(t, blocked, broker, 1)
|
||||
}
|
||||
|
||||
func requireBlockedEvalsEnqueued(t *testing.T, blocked *BlockedEvals, broker *EvalBroker, enqueued int) {
|
||||
t.Helper()
|
||||
|
||||
must.Wait(t, wait.InitialSuccess(
|
||||
wait.ErrorFunc(func() error {
|
||||
brokerStats := broker.Stats()
|
||||
if brokerStats.TotalReady != enqueued {
|
||||
return fmt.Errorf("missing enqueued evals: %#v", brokerStats)
|
||||
}
|
||||
return nil
|
||||
}),
|
||||
wait.Timeout(500*time.Millisecond),
|
||||
wait.Gap(10*time.Millisecond),
|
||||
))
|
||||
|
||||
blocked.pruneStats(time.Now().UTC())
|
||||
|
||||
// verfiy stats have been cleared
|
||||
stats := blocked.Stats()
|
||||
must.Eq(t, 0, stats.TotalBlocked)
|
||||
must.Eq(t, 0, stats.TotalEscaped)
|
||||
must.MapLen(t, 0, stats.BlockedResources.ByJob)
|
||||
}
|
||||
|
||||
func TestBlockedEvals_UnblockEligible(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
blocked, broker := testBlockedEvals(t)
|
||||
|
||||
// Create a blocked eval that is eligible on a specific node class and add
|
||||
// it to the blocked tracker.
|
||||
e := mock.BlockedEval()
|
||||
e.Status = structs.EvalStatusBlocked
|
||||
e.ClassEligibility = map[string]bool{"v1:123": true}
|
||||
<-blocked.Block(e)
|
||||
|
||||
// Verify block caused the eval to be tracked
|
||||
stats := blocked.Stats()
|
||||
must.Eq(t, 1, stats.TotalBlocked)
|
||||
|
||||
blocked.Unblock("v1:123", 1000)
|
||||
requireBlockedEvalsEnqueued(t, blocked, broker, 1)
|
||||
}
|
||||
|
||||
func TestBlockedEvals_UnblockIneligible(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
blocked, broker := testBlockedEvals(t)
|
||||
|
||||
// Create a blocked eval that is ineligible on a specific node class and add
|
||||
// it to the blocked tracker.
|
||||
e := mock.BlockedEval()
|
||||
e.ClassEligibility = map[string]bool{"v1:123": false}
|
||||
<-blocked.Block(e)
|
||||
|
||||
// Verify block caused the eval to be tracked
|
||||
stats := blocked.Stats()
|
||||
must.Eq(t, 1, stats.TotalBlocked)
|
||||
must.Eq(t, 0, stats.TotalEscaped)
|
||||
must.MapLen(t, 1, stats.BlockedResources.ByJob)
|
||||
|
||||
// Should do nothing
|
||||
<-blocked.Unblock("v1:123", 1000)
|
||||
|
||||
// Verify Unblock didn't cause an enqueue
|
||||
brokerStats := broker.Stats()
|
||||
must.Eq(t, 0, brokerStats.TotalReady, must.Sprintf("eval unblocked: %#v", brokerStats))
|
||||
|
||||
// Prune old and empty metrics.
|
||||
blocked.pruneStats(time.Now().UTC())
|
||||
|
||||
stats = blocked.Stats()
|
||||
must.Eq(t, 1, stats.TotalBlocked)
|
||||
must.Eq(t, 0, stats.TotalEscaped)
|
||||
must.MapLen(t, 1, stats.BlockedResources.ByJob)
|
||||
}
|
||||
|
||||
func TestBlockedEvals_UnblockUnknown(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
blocked, broker := testBlockedEvals(t)
|
||||
|
||||
// Create a blocked eval that is ineligible on a specific node class and add
|
||||
// it to the blocked tracker.
|
||||
e := mock.BlockedEval()
|
||||
e.ClassEligibility = map[string]bool{"v1:123": true, "v1:456": false}
|
||||
<-blocked.Block(e)
|
||||
|
||||
// Verify block caused the eval to be tracked.
|
||||
stats := blocked.Stats()
|
||||
must.Eq(t, 1, stats.TotalBlocked)
|
||||
must.Eq(t, 0, stats.TotalEscaped)
|
||||
must.MapLen(t, 1, stats.BlockedResources.ByJob)
|
||||
|
||||
// Should unblock because the eval hasn't seen this node class.
|
||||
<-blocked.Unblock("v1:789", 1000)
|
||||
requireBlockedEvalsEnqueued(t, blocked, broker, 1)
|
||||
}
|
||||
|
||||
func TestBlockedEvals_UnblockEligible_Quota(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
|
||||
blocked, broker := testBlockedEvals(t)
|
||||
|
||||
// Create a blocked eval that is eligible for a particular quota.
|
||||
e := mock.BlockedEval()
|
||||
e.QuotaLimitReached = "foo"
|
||||
<-blocked.Block(e)
|
||||
|
||||
// Verify block caused the eval to be tracked.
|
||||
stats := blocked.Stats()
|
||||
must.Eq(t, 1, stats.TotalBlocked)
|
||||
must.Eq(t, 1, stats.TotalQuotaLimit)
|
||||
must.MapLen(t, 1, stats.BlockedResources.ByJob)
|
||||
|
||||
<-blocked.UnblockQuota("foo", 1000)
|
||||
requireBlockedEvalsEnqueued(t, blocked, broker, 1)
|
||||
}
|
||||
|
||||
// The quota here is incidental. The eval is blocked due to something else,
|
||||
// e.g. cpu exhausted, but there happens to also be a quota on the namespace.
|
||||
func TestBlockedEvals_UnblockEligible_IncidentalQuota(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
blocked, broker := testBlockedEvals(t)
|
||||
|
||||
e := mock.BlockedEval()
|
||||
e.Status = structs.EvalStatusBlocked
|
||||
e.QuotaLimitReached = "" // explicitly not blocked due to quota limit
|
||||
<-blocked.Block(e)
|
||||
|
||||
// Verify block caused the eval to be tracked.
|
||||
stats := blocked.Stats()
|
||||
must.Eq(t, 1, stats.TotalBlocked)
|
||||
must.MapLen(t, 1, stats.BlockedResources.ByJob)
|
||||
// but not due to quota.
|
||||
must.Eq(t, 0, stats.TotalQuotaLimit)
|
||||
|
||||
// When unblocking, the quota name from the alloc is passed in,
|
||||
// regardless of the cause of the initial blockage.
|
||||
// Since the initial block in this test was due to something else,
|
||||
// it should be unblocked without regard to quota.
|
||||
<-blocked.UnblockQuota("foo", 1000)
|
||||
requireBlockedEvalsEnqueued(t, blocked, broker, 1)
|
||||
}
|
||||
|
||||
func TestBlockedEvals_UnblockIneligible_Quota(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
blocked, broker := testBlockedEvals(t)
|
||||
|
||||
// Create a blocked eval that is eligible on a specific quota.
|
||||
e := mock.BlockedEval()
|
||||
e.QuotaLimitReached = "foo"
|
||||
<-blocked.Block(e)
|
||||
|
||||
// Verify block caused the eval to be tracked.
|
||||
stats := blocked.Stats()
|
||||
must.Eq(t, 1, stats.TotalBlocked)
|
||||
must.Eq(t, 1, stats.TotalQuotaLimit)
|
||||
must.MapLen(t, 1, stats.BlockedResources.ByJob)
|
||||
|
||||
// Should do nothing and have no evals enqueued
|
||||
<-blocked.UnblockQuota("bar", 1000)
|
||||
brokerStats := broker.Stats()
|
||||
must.Eq(t, 0, brokerStats.TotalReady)
|
||||
|
||||
// Prune old and empty metrics.
|
||||
blocked.pruneStats(time.Now().UTC())
|
||||
|
||||
stats = blocked.Stats()
|
||||
must.Eq(t, 1, stats.TotalBlocked)
|
||||
must.Eq(t, 0, stats.TotalEscaped)
|
||||
must.Eq(t, 1, stats.TotalQuotaLimit)
|
||||
must.MapLen(t, 1, stats.BlockedResources.ByJob)
|
||||
}
|
||||
|
||||
func TestBlockedEvals_Reblock(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
blocked, broker := testBlockedEvals(t)
|
||||
|
||||
// Create an evaluation, Enqueue/Dequeue it to get a token
|
||||
e := mock.BlockedEval()
|
||||
e.SnapshotIndex = 500
|
||||
e.ClassEligibility = map[string]bool{"v1:123": true, "v1:456": false}
|
||||
broker.Enqueue(e)
|
||||
|
||||
_, token, err := broker.Dequeue([]string{e.Type}, time.Second)
|
||||
must.NoError(t, err)
|
||||
|
||||
// Reblock the evaluation
|
||||
<-blocked.Reblock(e, token)
|
||||
|
||||
// Verify block caused the eval to be tracked
|
||||
stats := blocked.Stats()
|
||||
must.Eq(t, 1, stats.TotalBlocked)
|
||||
must.Eq(t, 0, stats.TotalEscaped)
|
||||
must.MapLen(t, 1, stats.BlockedResources.ByJob)
|
||||
|
||||
// Should unblock because the eval
|
||||
<-blocked.Unblock("v1:123", 1000)
|
||||
|
||||
brokerStats := broker.Stats()
|
||||
must.Eq(t, 0, brokerStats.TotalReady)
|
||||
must.Eq(t, 1, brokerStats.TotalUnacked)
|
||||
|
||||
// Ack the evaluation which should cause the reblocked eval to transition
|
||||
// to ready
|
||||
err = broker.Ack(e.ID, token)
|
||||
must.NoError(t, err)
|
||||
|
||||
requireBlockedEvalsEnqueued(t, blocked, broker, 1)
|
||||
}
|
||||
|
||||
// Test the block case in which the eval should be immediately unblocked since
|
||||
// it is escaped and old
|
||||
func TestBlockedEvals_Block_ImmediateUnblock_Escaped(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
blocked, broker := testBlockedEvals(t)
|
||||
|
||||
// Do an unblock prior to blocking
|
||||
<-blocked.Unblock("v1:123", 1000)
|
||||
|
||||
// Create a blocked eval that is eligible on a specific node class and add
|
||||
// it to the blocked tracker.
|
||||
e := mock.BlockedEval()
|
||||
e.EscapedComputedClass = true
|
||||
e.SnapshotIndex = 900
|
||||
<-blocked.Block(e)
|
||||
|
||||
// Verify block caused the eval to be immediately unblocked
|
||||
stats := blocked.Stats()
|
||||
must.Eq(t, 0, stats.TotalBlocked)
|
||||
must.Eq(t, 0, stats.TotalEscaped)
|
||||
must.MapLen(t, 0, stats.BlockedResources.ByJob)
|
||||
|
||||
requireBlockedEvalsEnqueued(t, blocked, broker, 1)
|
||||
}
|
||||
|
||||
// Test the block case in which the eval should be immediately unblocked since
|
||||
// there is an unblock on an unseen class that occurred while it was in the
|
||||
// scheduler
|
||||
func TestBlockedEvals_Block_ImmediateUnblock_UnseenClass_After(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
|
||||
blocked, broker := testBlockedEvals(t)
|
||||
|
||||
// Do an unblock prior to blocking
|
||||
<-blocked.Unblock("v1:123", 1000)
|
||||
|
||||
// Create a blocked eval that is eligible on a specific node class and add
|
||||
// it to the blocked tracker.
|
||||
e := mock.BlockedEval()
|
||||
e.EscapedComputedClass = false
|
||||
e.SnapshotIndex = 900
|
||||
<-blocked.Block(e)
|
||||
|
||||
// Verify block caused the eval to be immediately unblocked
|
||||
stats := blocked.Stats()
|
||||
must.Eq(t, 0, stats.TotalBlocked)
|
||||
must.Eq(t, 0, stats.TotalEscaped)
|
||||
must.MapLen(t, 0, stats.BlockedResources.ByJob)
|
||||
|
||||
requireBlockedEvalsEnqueued(t, blocked, broker, 1)
|
||||
}
|
||||
|
||||
// Test the block case in which the eval should not immediately unblock since
|
||||
// there is an unblock on an unseen class that occurred before it was in the
|
||||
// scheduler
|
||||
func TestBlockedEvals_Block_ImmediateUnblock_UnseenClass_Before(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
blocked, _ := testBlockedEvals(t)
|
||||
|
||||
// Do an unblock prior to blocking
|
||||
<-blocked.Unblock("v1:123", 500)
|
||||
|
||||
// Create a blocked eval that is eligible on a specific node class and add
|
||||
// it to the blocked tracker.
|
||||
e := mock.BlockedEval()
|
||||
e.EscapedComputedClass = false
|
||||
e.SnapshotIndex = 900
|
||||
<-blocked.Block(e)
|
||||
|
||||
// Verify block caused the eval to be immediately unblocked
|
||||
stats := blocked.Stats()
|
||||
must.Eq(t, 1, stats.TotalBlocked)
|
||||
must.Eq(t, 0, stats.TotalEscaped)
|
||||
must.MapLen(t, 1, stats.BlockedResources.ByJob)
|
||||
}
|
||||
|
||||
// Test the block case in which the eval should be immediately unblocked since
|
||||
// it a class it is eligible for has been unblocked
|
||||
func TestBlockedEvals_Block_ImmediateUnblock_SeenClass(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
blocked, broker := testBlockedEvals(t)
|
||||
|
||||
// Do an unblock prior to blocking
|
||||
<-blocked.Unblock("v1:123", 1000)
|
||||
|
||||
// Create a blocked eval that is eligible on a specific node class and add
|
||||
// it to the blocked tracker.
|
||||
e := mock.BlockedEval()
|
||||
e.ClassEligibility = map[string]bool{"v1:123": true, "v1:456": false}
|
||||
e.SnapshotIndex = 900
|
||||
<-blocked.Block(e)
|
||||
|
||||
// Verify block caused the eval to be immediately unblocked
|
||||
stats := blocked.Stats()
|
||||
must.Eq(t, 0, stats.TotalBlocked)
|
||||
must.Eq(t, 0, stats.TotalEscaped)
|
||||
must.MapLen(t, 0, stats.BlockedResources.ByJob)
|
||||
|
||||
requireBlockedEvalsEnqueued(t, blocked, broker, 1)
|
||||
}
|
||||
|
||||
// Test the block case in which the eval should be immediately unblocked since
|
||||
// it a quota has changed that it is using
|
||||
func TestBlockedEvals_Block_ImmediateUnblock_Quota(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
blocked, broker := testBlockedEvals(t)
|
||||
|
||||
// Do an unblock prior to blocking
|
||||
<-blocked.UnblockQuota("my-quota", 1000)
|
||||
|
||||
// Create a blocked eval that is eligible on a specific node class and add
|
||||
// it to the blocked tracker.
|
||||
e := mock.BlockedEval()
|
||||
e.QuotaLimitReached = "my-quota"
|
||||
e.SnapshotIndex = 900
|
||||
<-blocked.Block(e)
|
||||
|
||||
// Verify block caused the eval to be immediately unblocked
|
||||
stats := blocked.Stats()
|
||||
must.Eq(t, 0, stats.TotalBlocked)
|
||||
must.Eq(t, 0, stats.TotalEscaped)
|
||||
must.Eq(t, 0, stats.TotalQuotaLimit)
|
||||
must.MapLen(t, 0, stats.BlockedResources.ByJob)
|
||||
|
||||
requireBlockedEvalsEnqueued(t, blocked, broker, 1)
|
||||
}
|
||||
|
||||
func TestBlockedEvals_UnblockFailed(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
|
||||
blocked, broker := testBlockedEvals(t)
|
||||
|
||||
// Create blocked evals that are due to failures
|
||||
e := mock.BlockedEval()
|
||||
e.TriggeredBy = structs.EvalTriggerMaxPlans
|
||||
e.EscapedComputedClass = true
|
||||
<-blocked.Block(e)
|
||||
|
||||
e2 := mock.BlockedEval()
|
||||
e2.Status = structs.EvalStatusBlocked
|
||||
e2.TriggeredBy = structs.EvalTriggerMaxPlans
|
||||
e2.ClassEligibility = map[string]bool{"v1:123": true, "v1:456": false}
|
||||
<-blocked.Block(e2)
|
||||
|
||||
e3 := mock.BlockedEval()
|
||||
e3.TriggeredBy = structs.EvalTriggerMaxPlans
|
||||
e3.QuotaLimitReached = "foo"
|
||||
<-blocked.Block(e3)
|
||||
|
||||
// Trigger an unblock fail
|
||||
blocked.UnblockFailed()
|
||||
|
||||
// Prune old and empty metrics.
|
||||
blocked.pruneStats(time.Now().UTC())
|
||||
|
||||
// Verify UnblockFailed caused the eval to be immediately unblocked
|
||||
stats := blocked.Stats()
|
||||
must.Eq(t, 0, stats.TotalBlocked)
|
||||
must.Eq(t, 0, stats.TotalEscaped)
|
||||
must.Eq(t, 0, stats.TotalQuotaLimit)
|
||||
must.MapLen(t, 0, stats.BlockedResources.ByJob)
|
||||
|
||||
requireBlockedEvalsEnqueued(t, blocked, broker, 3)
|
||||
|
||||
// Reblock an eval for the same job and check that it gets tracked.
|
||||
<-blocked.Block(e)
|
||||
stats = blocked.Stats()
|
||||
must.Eq(t, 1, stats.TotalBlocked)
|
||||
must.Eq(t, 1, stats.TotalEscaped)
|
||||
must.MapLen(t, 1, stats.BlockedResources.ByJob)
|
||||
}
|
||||
|
||||
func TestBlockedEvals_Untrack(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
blocked, _ := testBlockedEvals(t)
|
||||
|
||||
// Create blocked eval and add to the blocked tracker.
|
||||
e := mock.BlockedEval()
|
||||
e.ClassEligibility = map[string]bool{"v1:123": false, "v1:456": false}
|
||||
e.SnapshotIndex = 1000
|
||||
<-blocked.Block(e)
|
||||
|
||||
// Verify block did track
|
||||
stats := blocked.Stats()
|
||||
must.Eq(t, 1, stats.TotalBlocked)
|
||||
must.Eq(t, 0, stats.TotalEscaped)
|
||||
must.MapLen(t, 1, stats.BlockedResources.ByJob)
|
||||
|
||||
// Untrack and verify
|
||||
<-blocked.Untrack(e.JobID, e.Namespace)
|
||||
blocked.pruneStats(time.Now().UTC())
|
||||
|
||||
stats = blocked.Stats()
|
||||
must.Eq(t, 0, stats.TotalBlocked)
|
||||
must.Eq(t, 0, stats.TotalEscaped)
|
||||
must.MapLen(t, 0, stats.BlockedResources.ByJob)
|
||||
}
|
||||
|
||||
func TestBlockedEvals_Untrack_Quota(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
blocked, _ := testBlockedEvals(t)
|
||||
|
||||
// Create a blocked eval and add it to the blocked tracker.
|
||||
e := mock.BlockedEval()
|
||||
e.QuotaLimitReached = "foo"
|
||||
e.SnapshotIndex = 1000
|
||||
<-blocked.Block(e)
|
||||
|
||||
// Verify block did track
|
||||
stats := blocked.Stats()
|
||||
must.Eq(t, 1, stats.TotalBlocked)
|
||||
must.Eq(t, 0, stats.TotalEscaped)
|
||||
must.Eq(t, 1, stats.TotalQuotaLimit)
|
||||
must.MapLen(t, 1, stats.BlockedResources.ByJob)
|
||||
|
||||
// Untrack and verify
|
||||
<-blocked.Untrack(e.JobID, e.Namespace)
|
||||
stats = blocked.Stats()
|
||||
must.Eq(t, 0, stats.TotalBlocked)
|
||||
must.Eq(t, 0, stats.TotalEscaped)
|
||||
must.Eq(t, 0, stats.TotalQuotaLimit)
|
||||
must.MapLen(t, 1, stats.BlockedResources.ByJob)
|
||||
|
||||
now := time.Now().UTC()
|
||||
blocked.pruneStats(now)
|
||||
stats = blocked.Stats()
|
||||
must.Eq(t, 0, stats.TotalBlocked)
|
||||
must.Eq(t, 0, stats.TotalEscaped)
|
||||
must.Eq(t, 0, stats.TotalQuotaLimit)
|
||||
must.MapLen(t, 0, stats.BlockedResources.ByJob)
|
||||
}
|
||||
|
||||
func TestBlockedEvals_UnblockNode(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
|
||||
blocked, broker := testBlockedEvals(t)
|
||||
|
||||
// Create a blocked evals and add it to the blocked tracker.
|
||||
e := mock.BlockedEval()
|
||||
e.Type = structs.JobTypeSystem
|
||||
e.NodeID = "foo"
|
||||
e.SnapshotIndex = 999
|
||||
<-blocked.Block(e)
|
||||
|
||||
// Verify block did track
|
||||
stats := blocked.Stats()
|
||||
must.Eq(t, 1, stats.TotalBlocked)
|
||||
must.MapLen(t, 1, stats.BlockedResources.ByJob)
|
||||
|
||||
<-blocked.UnblockNode("foo")
|
||||
requireBlockedEvalsEnqueued(t, blocked, broker, 1)
|
||||
|
||||
blocked.pruneStats(time.Now().UTC())
|
||||
stats = blocked.Stats()
|
||||
must.MapEmpty(t, blocked.system.byNode)
|
||||
must.Eq(t, 0, stats.TotalBlocked)
|
||||
must.MapLen(t, 0, stats.BlockedResources.ByJob)
|
||||
}
|
||||
|
||||
func TestBlockedEvals_SystemUntrack(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
blocked, _ := testBlockedEvals(t)
|
||||
|
||||
// Create a blocked evals and add it to the blocked tracker.
|
||||
e := mock.Eval()
|
||||
e.Type = structs.JobTypeSystem
|
||||
e.NodeID = "foo"
|
||||
<-blocked.Block(e)
|
||||
|
||||
// Verify block did track
|
||||
stats := blocked.Stats()
|
||||
must.Eq(t, 1, stats.TotalBlocked)
|
||||
must.Eq(t, 0, stats.TotalEscaped)
|
||||
must.Eq(t, 0, stats.TotalQuotaLimit)
|
||||
must.MapLen(t, 1, stats.BlockedResources.ByJob)
|
||||
|
||||
// Untrack and verify
|
||||
<-blocked.Untrack(e.JobID, e.Namespace)
|
||||
blocked.pruneStats(time.Now().UTC())
|
||||
|
||||
stats = blocked.Stats()
|
||||
must.Eq(t, 0, stats.TotalBlocked)
|
||||
must.Eq(t, 0, stats.TotalEscaped)
|
||||
must.Eq(t, 0, stats.TotalQuotaLimit)
|
||||
must.MapLen(t, 0, stats.BlockedResources.ByJob)
|
||||
}
|
||||
|
||||
func TestBlockedEvals_SystemDisableFlush(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
blocked, _ := testBlockedEvals(t)
|
||||
|
||||
// Create a blocked evals and add it to the blocked tracker.
|
||||
e := mock.Eval()
|
||||
e.Type = structs.JobTypeSystem
|
||||
e.NodeID = "foo"
|
||||
<-blocked.Block(e)
|
||||
|
||||
// Verify block did track
|
||||
stats := blocked.Stats()
|
||||
must.Eq(t, 1, stats.TotalBlocked)
|
||||
must.Eq(t, 0, stats.TotalEscaped)
|
||||
must.Eq(t, 0, stats.TotalQuotaLimit)
|
||||
must.MapLen(t, 1, stats.BlockedResources.ByJob)
|
||||
|
||||
// Disable empties
|
||||
blocked.SetEnabled(false)
|
||||
stats = blocked.Stats()
|
||||
must.Eq(t, 0, stats.TotalBlocked)
|
||||
must.Eq(t, 0, stats.TotalEscaped)
|
||||
must.Eq(t, 0, stats.TotalQuotaLimit)
|
||||
must.MapLen(t, 0, stats.BlockedResources.ByJob)
|
||||
must.MapEmpty(t, blocked.system.evals)
|
||||
must.MapEmpty(t, blocked.system.byJob)
|
||||
must.MapEmpty(t, blocked.system.byNode)
|
||||
}
|
||||
|
||||
// TestBlockedEvals_UnblockBackpressure verifies that calling Unblock methods on
|
||||
// BlockedEvals doesn't result in backpressure that could impact the FSM.
|
||||
func TestBlockedEvals_UnblockBackpressure(t *testing.T) {
|
||||
pctx, cancel := context.WithCancel(context.TODO())
|
||||
t.Cleanup(cancel)
|
||||
|
||||
broker, err := NewEvalBroker(pctx, time.Second, 1, 1, 1)
|
||||
must.NoError(t, err)
|
||||
broker.SetEnabled(true)
|
||||
blockedEvals := NewBlockedEvals(broker, testlog.HCLogger(t))
|
||||
blockedEvals.SetEnabled(true)
|
||||
|
||||
for range 10000 {
|
||||
eval := mock.Eval()
|
||||
eval.QueuedAllocations = map[string]int{"web": 1}
|
||||
eval.ClassEligibility = map[string]bool{"foo": false}
|
||||
eval.EscapedComputedClass = true
|
||||
eval.Status = structs.EvalStatusBlocked
|
||||
|
||||
<-blockedEvals.Block(eval)
|
||||
}
|
||||
stats := blockedEvals.Stats()
|
||||
must.Eq(t, 10000, stats.TotalBlocked)
|
||||
must.Eq(t, 10000, stats.TotalEscaped)
|
||||
|
||||
stopCtx, stopCancel := context.WithCancel(pctx)
|
||||
t.Cleanup(stopCancel)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
errors := make(chan error, 144)
|
||||
|
||||
// this loop continuously calls unblock to trigger any contention we might
|
||||
// have; these calls should return quickly but be no-ops in watchCapacity
|
||||
// once we've unblocked all 10000 blocked evals
|
||||
//
|
||||
// Note that this test isn't particularly useful in CI where we have low
|
||||
// GOMAXPROCS. And with the fix it's intended to exercise, these Unblock
|
||||
// calls return too quickly for even highly concurrent continuous Reblock
|
||||
// loops to cause meaningful contention.
|
||||
for i := range 144 {
|
||||
wg.Go(func() {
|
||||
index := uint64(1000 * i)
|
||||
for {
|
||||
ctx, cancel := context.WithTimeout(stopCtx, 100*time.Millisecond)
|
||||
defer cancel()
|
||||
errCh := make(chan error)
|
||||
index++
|
||||
go func() {
|
||||
blockedEvals.Unblock("foo", index)
|
||||
close(errCh)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
errors <- fmt.Errorf("Unblock took more than 100ms to complete")
|
||||
}
|
||||
return
|
||||
case <-errCh:
|
||||
cancel()
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
})
|
||||
}
|
||||
|
||||
// wait until all 10000 evals are unblocked or 5 seconds pass, whichever is
|
||||
// longer, and collect any errors from goroutines that would indicate
|
||||
// they've blocked for longer than expected
|
||||
timeout := time.After(time.Second * 5)
|
||||
poll := time.NewTicker(10 * time.Millisecond)
|
||||
errCount := 0
|
||||
var lastErr error
|
||||
|
||||
DONE:
|
||||
for {
|
||||
select {
|
||||
case <-timeout:
|
||||
break DONE
|
||||
case <-poll.C:
|
||||
stats = blockedEvals.Stats()
|
||||
if stats.TotalBlocked == 0 && stats.TotalEscaped == 0 {
|
||||
break DONE
|
||||
}
|
||||
case err := <-errors:
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
errCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
must.NoError(t, lastErr, must.Sprintf("got %d errors", errCount))
|
||||
stats = blockedEvals.Stats()
|
||||
must.Eq(t, 0, stats.TotalBlocked)
|
||||
must.Eq(t, 0, stats.TotalEscaped)
|
||||
}
|
||||
@@ -1,610 +0,0 @@
|
||||
// Copyright IBM Corp. 2015, 2025
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package nomad
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
log "github.com/hashicorp/go-hclog"
|
||||
"github.com/hashicorp/go-msgpack/v2/codec"
|
||||
sframer "github.com/hashicorp/nomad/client/lib/streamframer"
|
||||
cstructs "github.com/hashicorp/nomad/client/structs"
|
||||
"github.com/hashicorp/nomad/command/agent/host"
|
||||
"github.com/hashicorp/nomad/command/agent/monitor"
|
||||
"github.com/hashicorp/nomad/command/agent/pprof"
|
||||
"github.com/hashicorp/nomad/helper/pointer"
|
||||
"github.com/hashicorp/nomad/nomad/peers"
|
||||
"github.com/hashicorp/nomad/nomad/structs"
|
||||
)
|
||||
|
||||
type Agent struct {
|
||||
srv *Server
|
||||
}
|
||||
|
||||
func NewAgentEndpoint(srv *Server) *Agent {
|
||||
return &Agent{srv: srv}
|
||||
}
|
||||
|
||||
func (a *Agent) register() {
|
||||
a.srv.streamingRpcs.Register("Agent.Monitor", a.monitor)
|
||||
a.srv.streamingRpcs.Register("Agent.MonitorExport", a.monitorExport)
|
||||
}
|
||||
|
||||
func (a *Agent) Profile(args *structs.AgentPprofRequest, reply *structs.AgentPprofResponse) error {
|
||||
authErr := a.srv.Authenticate(nil, args)
|
||||
a.srv.MeasureRPCRate("agent", structs.RateMetricRead, args)
|
||||
if authErr != nil {
|
||||
return structs.ErrPermissionDenied
|
||||
}
|
||||
|
||||
// Check ACL for agent write
|
||||
aclObj, err := a.srv.ResolveACL(args)
|
||||
if err != nil {
|
||||
return err
|
||||
} else if !aclObj.AllowAgentWrite() {
|
||||
// we're not checking AllowAgentDebug here because the target might not
|
||||
// be this server, and the server doesn't know if enable_debug has been
|
||||
// set on the target
|
||||
return structs.ErrPermissionDenied
|
||||
}
|
||||
|
||||
// Forward to different region if necessary
|
||||
// this would typically be done in a.srv.forward() but since
|
||||
// we are targeting a specific server, not just the leader
|
||||
// we must manually handle region forwarding here.
|
||||
region := args.RequestRegion()
|
||||
if region == "" {
|
||||
return fmt.Errorf("missing target RPC")
|
||||
}
|
||||
|
||||
if region != a.srv.Region() {
|
||||
// Mark that we are forwarding
|
||||
args.SetForwarded()
|
||||
return a.srv.forwardRegion(region, "Agent.Profile", args, reply)
|
||||
}
|
||||
|
||||
// Targeting a node, forward request to node
|
||||
if args.NodeID != "" {
|
||||
return a.forwardProfileClient(args, reply)
|
||||
}
|
||||
|
||||
// Handle serverID not equal to ours
|
||||
if args.ServerID != "" {
|
||||
serverToFwd, err := a.forwardFor(args.ServerID, region)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if serverToFwd != nil {
|
||||
return a.srv.forwardServer(serverToFwd, "Agent.Profile", args, reply)
|
||||
}
|
||||
}
|
||||
|
||||
// This server is the target, so now we can check for AllowAgentDebug
|
||||
if !aclObj.AllowAgentDebug(a.srv.GetConfig().EnableDebug) {
|
||||
return structs.ErrPermissionDenied
|
||||
}
|
||||
|
||||
// Process the request on this server
|
||||
var resp []byte
|
||||
var headers map[string]string
|
||||
|
||||
// Determine which profile to run and generate profile.
|
||||
// Blocks for args.Seconds
|
||||
// Our RPC endpoints currently don't support context
|
||||
// or request cancellation so using server shutdownCtx as a
|
||||
// best effort.
|
||||
switch args.ReqType {
|
||||
case pprof.CPUReq:
|
||||
resp, headers, err = pprof.CPUProfile(a.srv.shutdownCtx, args.Seconds)
|
||||
case pprof.CmdReq:
|
||||
resp, headers, err = pprof.Cmdline()
|
||||
case pprof.LookupReq:
|
||||
resp, headers, err = pprof.Profile(args.Profile, args.Debug, args.GC)
|
||||
case pprof.TraceReq:
|
||||
resp, headers, err = pprof.Trace(a.srv.shutdownCtx, args.Seconds)
|
||||
default:
|
||||
err = structs.NewErrRPCCoded(404, "Unknown profile request type")
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
if pprof.IsErrProfileNotFound(err) {
|
||||
return structs.NewErrRPCCoded(404, err.Error())
|
||||
}
|
||||
return structs.NewErrRPCCoded(500, err.Error())
|
||||
}
|
||||
|
||||
// Copy profile response to reply
|
||||
reply.Payload = resp
|
||||
reply.HTTPHeaders = headers
|
||||
reply.AgentID = a.srv.serf.LocalMember().Name
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *Agent) monitor(conn io.ReadWriteCloser) {
|
||||
defer conn.Close()
|
||||
|
||||
// Decode args
|
||||
var args cstructs.MonitorRequest
|
||||
decoder := codec.NewDecoder(conn, structs.MsgpackHandle)
|
||||
encoder := codec.NewEncoder(conn, structs.MsgpackHandle)
|
||||
|
||||
if err := decoder.Decode(&args); err != nil {
|
||||
handleStreamResultError(err, pointer.Of(int64(500)), encoder)
|
||||
return
|
||||
}
|
||||
authErr := a.srv.Authenticate(nil, &args)
|
||||
a.srv.MeasureRPCRate("agent", structs.RateMetricRead, &args)
|
||||
if authErr != nil {
|
||||
handleStreamResultError(structs.ErrPermissionDenied, nil, encoder)
|
||||
return
|
||||
}
|
||||
|
||||
// Check agent read permissions
|
||||
if aclObj, err := a.srv.ResolveACL(&args); err != nil {
|
||||
handleStreamResultError(err, nil, encoder)
|
||||
return
|
||||
} else if !aclObj.AllowAgentRead() {
|
||||
handleStreamResultError(structs.ErrPermissionDenied, pointer.Of(int64(403)), encoder)
|
||||
return
|
||||
}
|
||||
|
||||
logLevel := log.LevelFromString(args.LogLevel)
|
||||
if args.LogLevel == "" {
|
||||
logLevel = log.LevelFromString("INFO")
|
||||
}
|
||||
|
||||
if logLevel == log.NoLevel {
|
||||
handleStreamResultError(errors.New("Unknown log level"), pointer.Of(int64(400)), encoder)
|
||||
return
|
||||
}
|
||||
|
||||
// Targeting a node, forward request to node
|
||||
if args.NodeID != "" {
|
||||
a.forwardMonitorClient(conn, args, encoder, decoder, args.NodeID, "Agent.Monitor")
|
||||
// forwarded request has ended, return
|
||||
return
|
||||
}
|
||||
|
||||
region := args.RequestRegion()
|
||||
if region == "" {
|
||||
handleStreamResultError(fmt.Errorf("missing target region"), pointer.Of(int64(400)), encoder)
|
||||
return
|
||||
}
|
||||
if region != a.srv.Region() {
|
||||
// Mark that we are forwarding
|
||||
args.SetForwarded()
|
||||
}
|
||||
|
||||
// Try to forward request to remote region/server
|
||||
if args.ServerID != "" {
|
||||
serverToFwd, err := a.forwardFor(args.ServerID, region)
|
||||
if err != nil {
|
||||
handleStreamResultError(err, pointer.Of(int64(400)), encoder)
|
||||
return
|
||||
}
|
||||
if serverToFwd != nil {
|
||||
// Empty ServerID to prevent forwarding loop
|
||||
args.ServerID = ""
|
||||
a.forwardMonitorServer(conn, serverToFwd, args, encoder, decoder, "Agent.Monitor")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// NodeID was empty, ServerID was equal to this server, monitor this server
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
m := monitor.New(512, a.srv.logger, &log.LoggerOptions{
|
||||
Level: logLevel,
|
||||
JSONFormat: args.LogJSON,
|
||||
IncludeLocation: args.LogIncludeLocation,
|
||||
})
|
||||
|
||||
frames := make(chan *sframer.StreamFrame, 32)
|
||||
errCh := make(chan error)
|
||||
var buf bytes.Buffer
|
||||
frameCodec := codec.NewEncoder(&buf, structs.JsonHandle)
|
||||
|
||||
framer := sframer.NewStreamFramer(frames, 1*time.Second, 200*time.Millisecond, 1024)
|
||||
framer.Run()
|
||||
defer framer.Destroy()
|
||||
|
||||
// goroutine to detect remote side closing
|
||||
go func() {
|
||||
if _, err := conn.Read(nil); err != nil {
|
||||
// One end of the pipe explicitly closed, exit
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
<-ctx.Done()
|
||||
}()
|
||||
|
||||
logCh := m.Start()
|
||||
defer m.Stop()
|
||||
initialOffset := int64(0)
|
||||
|
||||
// receive logs and build frames
|
||||
go func() {
|
||||
defer framer.Destroy()
|
||||
LOOP:
|
||||
for {
|
||||
select {
|
||||
case log := <-logCh:
|
||||
if err := framer.Send("", "log", log, initialOffset); err != nil {
|
||||
select {
|
||||
case errCh <- err:
|
||||
case <-ctx.Done():
|
||||
}
|
||||
break LOOP
|
||||
}
|
||||
case <-ctx.Done():
|
||||
break LOOP
|
||||
}
|
||||
}
|
||||
}()
|
||||
streamEncoder := monitor.NewStreamEncoder(&buf, conn, encoder, frameCodec, args.PlainText)
|
||||
streamErr := streamEncoder.EncodeStream(frames, errCh, ctx, framer, false)
|
||||
if streamErr != nil {
|
||||
handleStreamResultError(streamErr, pointer.Of(int64(500)), encoder)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Agent) monitorExport(conn io.ReadWriteCloser) {
|
||||
defer conn.Close()
|
||||
// Decode args
|
||||
var args cstructs.MonitorExportRequest
|
||||
decoder := codec.NewDecoder(conn, structs.MsgpackHandle)
|
||||
encoder := codec.NewEncoder(conn, structs.MsgpackHandle)
|
||||
|
||||
if err := decoder.Decode(&args); err != nil {
|
||||
handleStreamResultError(err, pointer.Of(int64(500)), encoder)
|
||||
return
|
||||
}
|
||||
authErr := a.srv.Authenticate(nil, &args)
|
||||
a.srv.MeasureRPCRate("agent", structs.RateMetricRead, &args)
|
||||
if authErr != nil {
|
||||
handleStreamResultError(structs.ErrPermissionDenied, nil, encoder)
|
||||
return
|
||||
}
|
||||
|
||||
// Check agent read permissions
|
||||
if aclObj, err := a.srv.ResolveACL(&args); err != nil {
|
||||
handleStreamResultError(err, nil, encoder)
|
||||
return
|
||||
} else if !aclObj.AllowAgentRead() {
|
||||
handleStreamResultError(structs.ErrPermissionDenied, pointer.Of(int64(403)), encoder)
|
||||
return
|
||||
}
|
||||
|
||||
// Targeting a node, forward request to node
|
||||
if args.NodeID != "" {
|
||||
a.forwardMonitorClient(conn, args, encoder, decoder, args.NodeID, "Agent.MonitorExport")
|
||||
// forwarded request has ended, return
|
||||
return
|
||||
}
|
||||
|
||||
region := args.RequestRegion()
|
||||
if region == "" {
|
||||
handleStreamResultError(fmt.Errorf("missing target region"), pointer.Of(int64(400)), encoder)
|
||||
return
|
||||
}
|
||||
if region != a.srv.Region() {
|
||||
// Mark that we are forwarding
|
||||
args.SetForwarded()
|
||||
}
|
||||
|
||||
// Try to forward request to remote region/server
|
||||
if args.ServerID != "" {
|
||||
serverToFwd, err := a.forwardFor(args.ServerID, region)
|
||||
if err != nil {
|
||||
handleStreamResultError(err, pointer.Of(int64(400)), encoder)
|
||||
return
|
||||
}
|
||||
if serverToFwd != nil {
|
||||
//empty args.ServerID to prevent forwarding loop
|
||||
args.ServerID = ""
|
||||
a.forwardMonitorServer(conn, serverToFwd, args, encoder, decoder, "Agent.MonitorExport")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
nomadLogPath := a.srv.GetConfig().LogFile
|
||||
if args.OnDisk && nomadLogPath == "" {
|
||||
handleStreamResultError(errors.New("No nomad log file defined"), pointer.Of(int64(400)), encoder)
|
||||
}
|
||||
// NodeID was empty, ServerID was equal to this server, monitor this server
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
opts := monitor.MonitorExportOpts{
|
||||
Logger: a.srv.logger,
|
||||
LogsSince: args.LogsSince,
|
||||
ServiceName: args.ServiceName,
|
||||
NomadLogPath: nomadLogPath,
|
||||
OnDisk: args.OnDisk,
|
||||
Follow: args.Follow,
|
||||
Context: ctx,
|
||||
}
|
||||
|
||||
frames := make(chan *sframer.StreamFrame, 32)
|
||||
errCh := make(chan error)
|
||||
var buf bytes.Buffer
|
||||
frameSize := 1024
|
||||
frameCodec := codec.NewEncoder(&buf, structs.JsonHandle)
|
||||
|
||||
framer := sframer.NewStreamFramer(frames, 1*time.Second, 200*time.Millisecond, frameSize)
|
||||
framer.Run()
|
||||
defer framer.Destroy()
|
||||
|
||||
// goroutine to detect remote side closing
|
||||
go func() {
|
||||
if _, err := conn.Read(nil); err != nil {
|
||||
// One end of the pipe explicitly closed, exit
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
<-ctx.Done()
|
||||
}()
|
||||
m, err := monitor.NewExportMonitor(opts)
|
||||
if err != nil {
|
||||
handleStreamResultError(err, pointer.Of(int64(500)), encoder)
|
||||
return
|
||||
}
|
||||
|
||||
var eofCancelCh chan error
|
||||
|
||||
streamCh := m.Start()
|
||||
initialOffset := int64(0)
|
||||
eofCancel := !opts.Follow
|
||||
|
||||
streamEncoder := monitor.NewStreamEncoder(&buf, conn, encoder, frameCodec, args.PlainText)
|
||||
// receive logs and build frames
|
||||
streamReader := monitor.NewStreamReader(streamCh, framer, int64(frameSize))
|
||||
go func() {
|
||||
defer framer.Destroy()
|
||||
if err := streamReader.StreamFixed(ctx, initialOffset, "", 0, eofCancelCh, eofCancel); err != nil {
|
||||
select {
|
||||
case errCh <- err:
|
||||
case <-ctx.Done():
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
streamErr := streamEncoder.EncodeStream(frames, errCh, ctx, framer, true)
|
||||
if streamErr != nil {
|
||||
handleStreamResultError(streamErr, pointer.Of(int64(500)), encoder)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// forwardFor returns a serverParts for a request to be forwarded to.
|
||||
// A response of nil, nil indicates that the current server is equal to the
|
||||
// serverID and region so the request should not be forwarded.
|
||||
func (a *Agent) forwardFor(serverID, region string) (*peers.Parts, error) {
|
||||
var target *peers.Parts
|
||||
var err error
|
||||
|
||||
if serverID == "leader" {
|
||||
isLeader, remoteLeader := a.srv.getLeader()
|
||||
if !isLeader && remoteLeader != nil {
|
||||
target = remoteLeader
|
||||
} else if !isLeader && remoteLeader == nil {
|
||||
return nil, structs.ErrNoLeader
|
||||
} else if isLeader {
|
||||
// This server is current leader do not forward
|
||||
return nil, nil
|
||||
}
|
||||
} else {
|
||||
target, err = a.srv.getServer(region, serverID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// Unable to find a server
|
||||
if target == nil {
|
||||
return nil, fmt.Errorf("unknown nomad server %s", serverID)
|
||||
}
|
||||
|
||||
// ServerID is this current server,
|
||||
// No need to forward request
|
||||
if target.Name == a.srv.LocalMember().Name {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return target, nil
|
||||
}
|
||||
|
||||
func (a *Agent) forwardMonitorClient(conn io.ReadWriteCloser, args any, encoder *codec.Encoder, decoder *codec.Decoder, nodeID string, endpoint string) {
|
||||
// Get the Connection to the client either by fowarding to another server
|
||||
// or creating direct stream
|
||||
state, srv, err := a.findClientConn(nodeID)
|
||||
if err != nil {
|
||||
handleStreamResultError(err, pointer.Of(int64(500)), encoder)
|
||||
return
|
||||
}
|
||||
|
||||
var clientConn net.Conn
|
||||
|
||||
if state == nil {
|
||||
conn, err := a.srv.streamingRpc(srv, endpoint)
|
||||
if err != nil {
|
||||
handleStreamResultError(err, nil, encoder)
|
||||
return
|
||||
}
|
||||
|
||||
clientConn = conn
|
||||
} else {
|
||||
stream, err := NodeStreamingRpc(state.Session, endpoint)
|
||||
if err != nil {
|
||||
handleStreamResultError(err, nil, encoder)
|
||||
return
|
||||
}
|
||||
clientConn = stream
|
||||
}
|
||||
defer clientConn.Close()
|
||||
|
||||
// Send the Request
|
||||
outEncoder := codec.NewEncoder(clientConn, structs.MsgpackHandle)
|
||||
if err := outEncoder.Encode(args); err != nil {
|
||||
handleStreamResultError(err, nil, encoder)
|
||||
return
|
||||
}
|
||||
|
||||
structs.Bridge(conn, clientConn)
|
||||
}
|
||||
|
||||
func (a *Agent) forwardMonitorServer(conn io.ReadWriteCloser, server *peers.Parts, args any, encoder *codec.Encoder, decoder *codec.Decoder, endpoint string) {
|
||||
serverConn, err := a.srv.streamingRpc(server, "Agent.Monitor")
|
||||
if err != nil {
|
||||
handleStreamResultError(err, pointer.Of(int64(500)), encoder)
|
||||
return
|
||||
}
|
||||
defer serverConn.Close()
|
||||
|
||||
// Send the Request
|
||||
outEncoder := codec.NewEncoder(serverConn, structs.MsgpackHandle)
|
||||
if err := outEncoder.Encode(args); err != nil {
|
||||
handleStreamResultError(err, pointer.Of(int64(500)), encoder)
|
||||
return
|
||||
}
|
||||
|
||||
structs.Bridge(conn, serverConn)
|
||||
}
|
||||
|
||||
func (a *Agent) forwardProfileClient(args *structs.AgentPprofRequest, reply *structs.AgentPprofResponse) error {
|
||||
state, srv, err := a.findClientConn(args.NodeID)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if srv != nil {
|
||||
return a.srv.forwardServer(srv, "Agent.Profile", args, reply)
|
||||
}
|
||||
|
||||
// NodeRpc
|
||||
rpcErr := NodeRpc(state.Session, "Agent.Profile", args, reply)
|
||||
if rpcErr != nil {
|
||||
return rpcErr
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Host returns data about the agent's host system for the `debug` command.
|
||||
func (a *Agent) Host(args *structs.HostDataRequest, reply *structs.HostDataResponse) error {
|
||||
authErr := a.srv.Authenticate(nil, args)
|
||||
a.srv.MeasureRPCRate("agent", structs.RateMetricRead, args)
|
||||
if authErr != nil {
|
||||
return structs.ErrPermissionDenied
|
||||
}
|
||||
aclObj, err := a.srv.ResolveACL(args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !aclObj.AllowAgentDebug(a.srv.GetConfig().EnableDebug) {
|
||||
return structs.ErrPermissionDenied
|
||||
}
|
||||
|
||||
// Forward to different region if necessary
|
||||
// this would typically be done in a.srv.forward() but since
|
||||
// we are targeting a specific server, not just the leader
|
||||
// we must manually handle region forwarding here.
|
||||
region := args.RequestRegion()
|
||||
if region == "" {
|
||||
return fmt.Errorf("missing target RPC")
|
||||
}
|
||||
|
||||
if region != a.srv.Region() {
|
||||
// Mark that we are forwarding
|
||||
args.SetForwarded()
|
||||
return a.srv.forwardRegion(region, "Agent.Host", args, reply)
|
||||
}
|
||||
|
||||
// Targeting a client node, forward request to node
|
||||
if args.NodeID != "" {
|
||||
client, srv, err := a.findClientConn(args.NodeID)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if srv != nil {
|
||||
return a.srv.forwardServer(srv, "Agent.Host", args, reply)
|
||||
}
|
||||
|
||||
return NodeRpc(client.Session, "Agent.Host", args, reply)
|
||||
}
|
||||
|
||||
// Handle serverID not equal to ours
|
||||
if args.ServerID != "" {
|
||||
srv, err := a.forwardFor(args.ServerID, region)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if srv != nil {
|
||||
return a.srv.forwardServer(srv, "Agent.Host", args, reply)
|
||||
}
|
||||
}
|
||||
|
||||
data, err := host.MakeHostData()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
reply.AgentID = a.srv.serf.LocalMember().Name
|
||||
reply.HostData = data
|
||||
return nil
|
||||
}
|
||||
|
||||
// findClientConn is a helper that returns a connection to the client node or, if the client
|
||||
// is connected to a different server, a serverParts describing the server to which the
|
||||
// client bound RPC should be forwarded.
|
||||
func (a *Agent) findClientConn(nodeID string) (*nodeConnState, *peers.Parts, error) {
|
||||
snap, err := a.srv.State().Snapshot()
|
||||
if err != nil {
|
||||
return nil, nil, structs.NewErrRPCCoded(500, err.Error())
|
||||
}
|
||||
|
||||
node, err := snap.NodeByID(nil, nodeID)
|
||||
if err != nil {
|
||||
return nil, nil, structs.NewErrRPCCoded(500, err.Error())
|
||||
}
|
||||
|
||||
if node == nil {
|
||||
err := fmt.Errorf("Unknown node %q", nodeID)
|
||||
return nil, nil, structs.NewErrRPCCoded(404, err.Error())
|
||||
}
|
||||
|
||||
if err := nodeSupportsRpc(node); err != nil {
|
||||
return nil, nil, structs.NewErrRPCCoded(400, err.Error())
|
||||
}
|
||||
|
||||
// Get the Connection to the client either by fowarding to another server
|
||||
// or creating direct stream
|
||||
state, ok := a.srv.getNodeConn(nodeID)
|
||||
if ok {
|
||||
return state, nil, nil
|
||||
}
|
||||
|
||||
// Determine the server that has a connection to the node
|
||||
srv, err := a.srv.serverWithNodeConn(nodeID, a.srv.Region())
|
||||
if err != nil {
|
||||
code := 500
|
||||
if structs.IsErrNoNodeConn(err) {
|
||||
code = 404
|
||||
}
|
||||
return nil, nil, structs.NewErrRPCCoded(code, err.Error())
|
||||
}
|
||||
|
||||
return nil, srv, nil
|
||||
}
|
||||
-1106
File diff suppressed because it is too large.
Load diff
@@ -1,639 +0,0 @@
|
||||
// Copyright IBM Corp. 2015, 2025
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package nomad
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/hashicorp/go-hclog"
|
||||
metrics "github.com/hashicorp/go-metrics/compat"
|
||||
"github.com/hashicorp/go-msgpack/v2/codec"
|
||||
|
||||
"github.com/hashicorp/nomad/acl"
|
||||
cstructs "github.com/hashicorp/nomad/client/structs"
|
||||
"github.com/hashicorp/nomad/helper/pointer"
|
||||
"github.com/hashicorp/nomad/nomad/structs"
|
||||
)
|
||||
|
||||
// ClientAllocations is used to forward RPC requests to the targeted Nomad client's
|
||||
// Allocation endpoint.
|
||||
type ClientAllocations struct {
|
||||
srv *Server
|
||||
logger hclog.Logger
|
||||
}
|
||||
|
||||
func NewClientAllocationsEndpoint(srv *Server) *ClientAllocations {
|
||||
return &ClientAllocations{srv: srv, logger: srv.logger.Named("client_allocs")}
|
||||
}
|
||||
|
||||
func (a *ClientAllocations) register() {
|
||||
a.srv.streamingRpcs.Register("Allocations.Exec", a.exec)
|
||||
}
|
||||
|
||||
// GarbageCollectAll is used to garbage collect all allocations on a client.
|
||||
func (a *ClientAllocations) GarbageCollectAll(args *structs.NodeSpecificRequest, reply *structs.GenericResponse) error {
|
||||
// We only allow stale reads since the only potentially stale information is
|
||||
// the Node registration and the cost is fairly high for adding another hop
|
||||
// in the forwarding chain.
|
||||
args.QueryOptions.AllowStale = true
|
||||
|
||||
authErr := a.srv.Authenticate(nil, args)
|
||||
|
||||
// Potentially forward to a different region.
|
||||
if done, err := a.srv.forward("ClientAllocations.GarbageCollectAll", args, args, reply); done {
|
||||
return err
|
||||
}
|
||||
a.srv.MeasureRPCRate("client_allocations", structs.RateMetricWrite, args)
|
||||
if authErr != nil {
|
||||
return structs.ErrPermissionDenied
|
||||
}
|
||||
defer metrics.MeasureSince([]string{"nomad", "client_allocations", "garbage_collect_all"}, time.Now())
|
||||
|
||||
// Check node read permissions
|
||||
if aclObj, err := a.srv.ResolveACL(args); err != nil {
|
||||
return err
|
||||
} else if !aclObj.AllowNodeWrite() {
|
||||
return structs.ErrPermissionDenied
|
||||
}
|
||||
|
||||
// Verify the arguments.
|
||||
if args.NodeID == "" {
|
||||
return errors.New("missing NodeID")
|
||||
}
|
||||
|
||||
// Make sure Node is valid and new enough to support RPC
|
||||
snap, err := a.srv.State().Snapshot()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = getNodeForRpc(snap, args.NodeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Get the connection to the client
|
||||
state, ok := a.srv.getNodeConn(args.NodeID)
|
||||
if !ok {
|
||||
return findNodeConnAndForward(a.srv, args.NodeID, "ClientAllocations.GarbageCollectAll", args, reply)
|
||||
}
|
||||
|
||||
// Make the RPC
|
||||
return NodeRpc(state.Session, "Allocations.GarbageCollectAll", args, reply)
|
||||
}
|
||||
|
||||
// Signal is used to send a signal to an allocation on a client.
|
||||
func (a *ClientAllocations) Signal(args *structs.AllocSignalRequest, reply *structs.GenericResponse) error {
|
||||
// We only allow stale reads since the only potentially stale information is
|
||||
// the Node registration and the cost is fairly high for adding another hope
|
||||
// in the forwarding chain.
|
||||
args.QueryOptions.AllowStale = true
|
||||
|
||||
authErr := a.srv.Authenticate(nil, args)
|
||||
|
||||
// Potentially forward to a different region.
|
||||
if done, err := a.srv.forward("ClientAllocations.Signal", args, args, reply); done {
|
||||
return err
|
||||
}
|
||||
a.srv.MeasureRPCRate("client_allocations", structs.RateMetricWrite, args)
|
||||
if authErr != nil {
|
||||
return structs.ErrPermissionDenied
|
||||
}
|
||||
defer metrics.MeasureSince([]string{"nomad", "client_allocations", "signal"}, time.Now())
|
||||
|
||||
// Verify the arguments.
|
||||
if args.AllocID == "" {
|
||||
return errors.New("missing AllocID")
|
||||
}
|
||||
|
||||
// Find the allocation
|
||||
snap, err := a.srv.State().Snapshot()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
alloc, err := getAlloc(snap, args.AllocID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check namespace alloc-lifecycle permission.
|
||||
if aclObj, err := a.srv.ResolveACL(args); err != nil {
|
||||
return err
|
||||
} else if !aclObj.AllowNsOp(alloc.Namespace, acl.NamespaceCapabilityAllocLifecycle) {
|
||||
return structs.ErrPermissionDenied
|
||||
}
|
||||
|
||||
// Make sure Node is valid and new enough to support RPC
|
||||
_, err = getNodeForRpc(snap, alloc.NodeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Get the connection to the client
|
||||
state, ok := a.srv.getNodeConn(alloc.NodeID)
|
||||
if !ok {
|
||||
return findNodeConnAndForward(a.srv, alloc.NodeID, "ClientAllocations.Signal", args, reply)
|
||||
}
|
||||
|
||||
// Make the RPC
|
||||
return NodeRpc(state.Session, "Allocations.Signal", args, reply)
|
||||
}
|
||||
|
||||
func (a *ClientAllocations) SetPauseState(args *structs.AllocPauseRequest, reply *structs.GenericResponse) error {
|
||||
args.QueryOptions.AllowStale = true
|
||||
authErr := a.srv.Authenticate(nil, args)
|
||||
|
||||
// Potentially forward to a different region.
|
||||
if done, err := a.srv.forward("ClientAllocations.SetPauseState", args, args, reply); done {
|
||||
return err
|
||||
}
|
||||
a.srv.MeasureRPCRate("client_allocations", structs.RateMetricWrite, args)
|
||||
if authErr != nil {
|
||||
return structs.ErrPermissionDenied
|
||||
}
|
||||
defer metrics.MeasureSince([]string{"nomad", "client_allocations", "pause_set"}, time.Now())
|
||||
|
||||
// Verify the arguments.
|
||||
if args.AllocID == "" {
|
||||
return errors.New("missing AllocID")
|
||||
}
|
||||
|
||||
// Find the allocation.
|
||||
snap, err := a.srv.State().Snapshot()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
alloc, err := getAlloc(snap, args.AllocID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check namespace submit-job permission.
|
||||
if aclObj, err := a.srv.ResolveACL(args); err != nil {
|
||||
return err
|
||||
} else if !aclObj.AllowNsOpAnyOf(alloc.Namespace,
|
||||
acl.NamespaceCapabilitySubmitJob,
|
||||
acl.NamespaceCapabilityPauseAllocation,
|
||||
) {
|
||||
return structs.ErrPermissionDenied
|
||||
}
|
||||
|
||||
// Make sure the node is valid and new enough to support RPC
|
||||
_, err = getNodeForRpc(snap, alloc.NodeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Get the connection to the client
|
||||
state, ok := a.srv.getNodeConn(alloc.NodeID)
|
||||
if !ok {
|
||||
return findNodeConnAndForward(a.srv, alloc.NodeID, "ClientAllocations.SetPauseState", args, reply)
|
||||
}
|
||||
|
||||
// Make the RPC
|
||||
return NodeRpc(state.Session, "Allocations.SetPauseState", args, reply)
|
||||
}
|
||||
|
||||
func (a *ClientAllocations) GetPauseState(args *structs.AllocGetPauseStateRequest, reply *structs.AllocGetPauseStateResponse) error {
|
||||
args.QueryOptions.AllowStale = true
|
||||
authErr := a.srv.Authenticate(nil, args)
|
||||
|
||||
// Potentially forward to a different region.
|
||||
if done, err := a.srv.forward("ClientAllocations.GetPauseState", args, args, reply); done {
|
||||
return err
|
||||
}
|
||||
a.srv.MeasureRPCRate("client_allocations", structs.RateMetricRead, args)
|
||||
if authErr != nil {
|
||||
return structs.ErrPermissionDenied
|
||||
}
|
||||
defer metrics.MeasureSince([]string{"nomad", "client_allocations", "pause_get"}, time.Now())
|
||||
|
||||
// Verify the arguments.
|
||||
if args.AllocID == "" {
|
||||
return errors.New("missing AllocID")
|
||||
}
|
||||
|
||||
// Find the allocation.
|
||||
snap, err := a.srv.State().Snapshot()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
alloc, err := getAlloc(snap, args.AllocID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check namespace read-job permission.
|
||||
if aclObj, err := a.srv.ResolveACL(args); err != nil {
|
||||
return err
|
||||
} else if !aclObj.AllowNsOp(alloc.Namespace, acl.NamespaceCapabilityReadJob) {
|
||||
return structs.ErrPermissionDenied
|
||||
}
|
||||
|
||||
// Make sure the node is valid and new enough to support RPC
|
||||
_, err = getNodeForRpc(snap, alloc.NodeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Get the connection to the client
|
||||
state, ok := a.srv.getNodeConn(alloc.NodeID)
|
||||
if !ok {
|
||||
return findNodeConnAndForward(a.srv, alloc.NodeID, "ClientAllocations.GetPauseState", args, reply)
|
||||
}
|
||||
|
||||
// Make the RPC
|
||||
return NodeRpc(state.Session, "Allocations.GetPauseState", args, reply)
|
||||
}
|
||||
|
||||
// GarbageCollect is used to garbage collect an allocation on a client.
|
||||
func (a *ClientAllocations) GarbageCollect(args *structs.AllocSpecificRequest, reply *structs.GenericResponse) error {
|
||||
// We only allow stale reads since the only potentially stale information is
|
||||
// the Node registration and the cost is fairly high for adding another hop
|
||||
// in the forwarding chain.
|
||||
args.QueryOptions.AllowStale = true
|
||||
|
||||
authErr := a.srv.Authenticate(nil, args)
|
||||
|
||||
// Potentially forward to a different region.
|
||||
if done, err := a.srv.forward("ClientAllocations.GarbageCollect", args, args, reply); done {
|
||||
return err
|
||||
}
|
||||
a.srv.MeasureRPCRate("client_allocations", structs.RateMetricWrite, args)
|
||||
if authErr != nil {
|
||||
return structs.ErrPermissionDenied
|
||||
}
|
||||
defer metrics.MeasureSince([]string{"nomad", "client_allocations", "garbage_collect"}, time.Now())
|
||||
|
||||
// Verify the arguments.
|
||||
if args.AllocID == "" {
|
||||
return errors.New("missing AllocID")
|
||||
}
|
||||
|
||||
// Find the allocation
|
||||
snap, err := a.srv.State().Snapshot()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
alloc, err := getAlloc(snap, args.AllocID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check namespace submit-job permission.
|
||||
if aclObj, err := a.srv.ResolveACL(args); err != nil {
|
||||
return err
|
||||
} else if !aclObj.AllowNsOpAnyOf(alloc.Namespace,
|
||||
acl.NamespaceCapabilitySubmitJob,
|
||||
acl.NamespaceCapabilityGCAllocation,
|
||||
) {
|
||||
return structs.ErrPermissionDenied
|
||||
}
|
||||
|
||||
// Make sure Node is valid and new enough to support RPC
|
||||
_, err = getNodeForRpc(snap, alloc.NodeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Get the connection to the client
|
||||
state, ok := a.srv.getNodeConn(alloc.NodeID)
|
||||
if !ok {
|
||||
return findNodeConnAndForward(a.srv, alloc.NodeID, "ClientAllocations.GarbageCollect", args, reply)
|
||||
}
|
||||
|
||||
// Make the RPC
|
||||
return NodeRpc(state.Session, "Allocations.GarbageCollect", args, reply)
|
||||
}
|
||||
|
||||
// Restart is used to trigger a restart of an allocation or a subtask on a client.
|
||||
func (a *ClientAllocations) Restart(args *structs.AllocRestartRequest, reply *structs.GenericResponse) error {
|
||||
// We only allow stale reads since the only potentially stale information is
|
||||
// the Node registration and the cost is fairly high for adding another hop
|
||||
// in the forwarding chain.
|
||||
args.QueryOptions.AllowStale = true
|
||||
|
||||
authErr := a.srv.Authenticate(nil, args)
|
||||
|
||||
// Potentially forward to a different region.
|
||||
if done, err := a.srv.forward("ClientAllocations.Restart", args, args, reply); done {
|
||||
return err
|
||||
}
|
||||
a.srv.MeasureRPCRate("client_allocations", structs.RateMetricWrite, args)
|
||||
if authErr != nil {
|
||||
return structs.ErrPermissionDenied
|
||||
}
|
||||
defer metrics.MeasureSince([]string{"nomad", "client_allocations", "restart"}, time.Now())
|
||||
|
||||
// Find the allocation
|
||||
snap, err := a.srv.State().Snapshot()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
alloc, err := getAlloc(snap, args.AllocID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check for namespace alloc-lifecycle permissions.
|
||||
if aclObj, err := a.srv.ResolveACL(args); err != nil {
|
||||
return err
|
||||
} else if !aclObj.AllowNsOp(alloc.Namespace, acl.NamespaceCapabilityAllocLifecycle) {
|
||||
return structs.ErrPermissionDenied
|
||||
}
|
||||
|
||||
// Make sure Node is valid and new enough to support RPC
|
||||
_, err = getNodeForRpc(snap, alloc.NodeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Get the connection to the client
|
||||
state, ok := a.srv.getNodeConn(alloc.NodeID)
|
||||
if !ok {
|
||||
return findNodeConnAndForward(a.srv, alloc.NodeID, "ClientAllocations.Restart", args, reply)
|
||||
}
|
||||
|
||||
// Make the RPC
|
||||
return NodeRpc(state.Session, "Allocations.Restart", args, reply)
|
||||
}
|
||||
|
||||
// Stats is used to collect allocation statistics
|
||||
func (a *ClientAllocations) Stats(args *cstructs.AllocStatsRequest, reply *cstructs.AllocStatsResponse) error {
|
||||
// We only allow stale reads since the only potentially stale information is
|
||||
// the Node registration and the cost is fairly high for adding another hop
|
||||
// in the forwarding chain.
|
||||
args.QueryOptions.AllowStale = true
|
||||
|
||||
authErr := a.srv.Authenticate(nil, args)
|
||||
|
||||
// Potentially forward to a different region.
|
||||
if done, err := a.srv.forward("ClientAllocations.Stats", args, args, reply); done {
|
||||
return err
|
||||
}
|
||||
a.srv.MeasureRPCRate("client_allocations", structs.RateMetricRead, args)
|
||||
if authErr != nil {
|
||||
return structs.ErrPermissionDenied
|
||||
}
|
||||
defer metrics.MeasureSince([]string{"nomad", "client_allocations", "stats"}, time.Now())
|
||||
|
||||
// Find the allocation
|
||||
snap, err := a.srv.State().Snapshot()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
alloc, err := getAlloc(snap, args.AllocID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check for namespace read-job permissions.
|
||||
if aclObj, err := a.srv.ResolveACL(args); err != nil {
|
||||
return err
|
||||
} else if !aclObj.AllowNsOp(alloc.Namespace, acl.NamespaceCapabilityReadJob) {
|
||||
return structs.ErrPermissionDenied
|
||||
}
|
||||
|
||||
// Make sure Node is valid and new enough to support RPC
|
||||
_, err = getNodeForRpc(snap, alloc.NodeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Get the connection to the client
|
||||
state, ok := a.srv.getNodeConn(alloc.NodeID)
|
||||
if !ok {
|
||||
return findNodeConnAndForward(a.srv, alloc.NodeID, "ClientAllocations.Stats", args, reply)
|
||||
}
|
||||
|
||||
// Make the RPC
|
||||
return NodeRpc(state.Session, "Allocations.Stats", args, reply)
|
||||
}
|
||||
|
||||
// Checks is the server implementation of the allocation checks RPC. The
|
||||
// ultimate response is provided by the node running the allocation. This RPC
|
||||
// is needed to handle queries which hit the server agent API directly, or via
|
||||
// another node which is not running the allocation.
|
||||
func (a *ClientAllocations) Checks(args *cstructs.AllocChecksRequest, reply *cstructs.AllocChecksResponse) error {
|
||||
|
||||
// We only allow stale reads since the only potentially stale information
|
||||
// is the Node registration and the cost is fairly high for adding another
|
||||
// hop in the forwarding chain.
|
||||
args.QueryOptions.AllowStale = true
|
||||
|
||||
authErr := a.srv.Authenticate(nil, args)
|
||||
|
||||
// Potentially forward to a different region.
|
||||
if done, err := a.srv.forward("ClientAllocations.Checks", args, args, reply); done {
|
||||
return err
|
||||
}
|
||||
a.srv.MeasureRPCRate("client_allocations", structs.RateMetricRead, args)
|
||||
if authErr != nil {
|
||||
return structs.ErrPermissionDenied
|
||||
}
|
||||
defer metrics.MeasureSince([]string{"nomad", "client_allocations", "checks"}, time.Now())
|
||||
|
||||
// Grab the state snapshot, as we need this to perform lookups for a number
|
||||
// of objects, all things being well.
|
||||
snap, err := a.srv.State().Snapshot()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Get the full allocation object, so we have information such as the
|
||||
// namespace and node ID.
|
||||
alloc, err := getAlloc(snap, args.AllocID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check for namespace read-job permissions.
|
||||
if aclObj, err := a.srv.ResolveACL(args); err != nil {
|
||||
return err
|
||||
} else if !aclObj.AllowNsOp(alloc.Namespace, acl.NamespaceCapabilityReadJob) {
|
||||
return structs.ErrPermissionDenied
|
||||
}
|
||||
|
||||
// Make sure Node is valid and new enough to support RPC.
|
||||
if _, err = getNodeForRpc(snap, alloc.NodeID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Get the connection to the client.
|
||||
state, ok := a.srv.getNodeConn(alloc.NodeID)
|
||||
if !ok {
|
||||
return findNodeConnAndForward(a.srv, alloc.NodeID, "ClientAllocations.Checks", args, reply)
|
||||
}
|
||||
|
||||
// Make the RPC
|
||||
return NodeRpc(state.Session, "Allocations.Checks", args, reply)
|
||||
}
|
||||
|
||||
// exec is used to execute command in a running task
|
||||
func (a *ClientAllocations) exec(conn io.ReadWriteCloser) {
|
||||
defer conn.Close()
|
||||
defer metrics.MeasureSince([]string{"nomad", "alloc", "exec"}, time.Now())
|
||||
|
||||
// Decode the arguments
|
||||
var args cstructs.AllocExecRequest
|
||||
decoder := codec.NewDecoder(conn, structs.MsgpackHandle)
|
||||
encoder := codec.NewEncoder(conn, structs.MsgpackHandle)
|
||||
|
||||
if err := decoder.Decode(&args); err != nil {
|
||||
handleStreamResultError(err, pointer.Of(int64(500)), encoder)
|
||||
return
|
||||
}
|
||||
|
||||
authErr := a.srv.Authenticate(nil, &args)
|
||||
|
||||
// Check if we need to forward to a different region
|
||||
if r := args.RequestRegion(); r != a.srv.Region() {
|
||||
forwardRegionStreamingRpc(a.srv, conn, encoder, &args, "Allocations.Exec",
|
||||
args.AllocID, &args.QueryOptions)
|
||||
return
|
||||
}
|
||||
a.srv.MeasureRPCRate("client_allocations", structs.RateMetricWrite, &args)
|
||||
if authErr != nil {
|
||||
handleStreamResultError(structs.ErrPermissionDenied, nil, encoder)
|
||||
return
|
||||
}
|
||||
|
||||
// Verify the arguments.
|
||||
if args.AllocID == "" {
|
||||
handleStreamResultError(errors.New("missing AllocID"), pointer.Of(int64(400)), encoder)
|
||||
return
|
||||
}
|
||||
|
||||
// Retrieve the allocation
|
||||
snap, err := a.srv.State().Snapshot()
|
||||
if err != nil {
|
||||
handleStreamResultError(err, nil, encoder)
|
||||
return
|
||||
}
|
||||
|
||||
alloc, err := getAlloc(snap, args.AllocID)
|
||||
if structs.IsErrUnknownAllocation(err) {
|
||||
handleStreamResultError(err, pointer.Of(int64(404)), encoder)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
handleStreamResultError(err, nil, encoder)
|
||||
return
|
||||
}
|
||||
|
||||
// Check node read permissions
|
||||
if aclObj, err := a.srv.ResolveACL(&args); err != nil {
|
||||
handleStreamResultError(err, nil, encoder)
|
||||
return
|
||||
} else if !aclObj.AllowNsOp(alloc.Namespace, acl.NamespaceCapabilityAllocExec) {
|
||||
// client ultimately checks if AllocNodeExec is required
|
||||
handleStreamResultError(structs.ErrPermissionDenied, nil, encoder)
|
||||
return
|
||||
}
|
||||
|
||||
if alloc.ClientTerminalStatus() {
|
||||
handleStreamResultError(fmt.Errorf("exec not possible, client status of allocation %s is %s", alloc.ID, alloc.ClientStatus),
|
||||
pointer.Of(int64(http.StatusBadRequest)), encoder)
|
||||
return
|
||||
}
|
||||
|
||||
// Handle job ID if requested.
|
||||
if args.JobID != "" {
|
||||
// Verify job exists.
|
||||
job, err := snap.JobByID(nil, args.Namespace, args.JobID)
|
||||
if err != nil {
|
||||
handleStreamResultError(err,
|
||||
pointer.Of(int64(http.StatusInternalServerError)), encoder)
|
||||
return
|
||||
}
|
||||
if job == nil {
|
||||
handleStreamResultError(
|
||||
fmt.Errorf("job %s not found in namespace %s", args.JobID, args.Namespace),
|
||||
pointer.Of(int64(http.StatusNotFound)), encoder)
|
||||
return
|
||||
}
|
||||
|
||||
// Verify requested allocation belongs to the job.
|
||||
if args.JobID != alloc.JobID {
|
||||
handleStreamResultError(
|
||||
fmt.Errorf("job %s does not have allocation %s", args.JobID, alloc.ID),
|
||||
pointer.Of(int64(http.StatusBadRequest)), encoder,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
nodeID := alloc.NodeID
|
||||
|
||||
// Make sure Node is valid and new enough to support RPC
|
||||
node, err := snap.NodeByID(nil, nodeID)
|
||||
if err != nil {
|
||||
handleStreamResultError(err, pointer.Of(int64(500)), encoder)
|
||||
return
|
||||
}
|
||||
|
||||
if node == nil {
|
||||
err := fmt.Errorf("Unknown node %q", nodeID)
|
||||
handleStreamResultError(err, pointer.Of(int64(400)), encoder)
|
||||
return
|
||||
}
|
||||
|
||||
if err := nodeSupportsRpc(node); err != nil {
|
||||
handleStreamResultError(err, pointer.Of(int64(400)), encoder)
|
||||
return
|
||||
}
|
||||
|
||||
// Get the connection to the client either by forwarding to another server
|
||||
// or creating a direct stream
|
||||
var clientConn net.Conn
|
||||
state, ok := a.srv.getNodeConn(nodeID)
|
||||
if !ok {
|
||||
// Determine the Server that has a connection to the node.
|
||||
srv, err := a.srv.serverWithNodeConn(nodeID, a.srv.Region())
|
||||
if err != nil {
|
||||
var code *int64
|
||||
if structs.IsErrNoNodeConn(err) {
|
||||
code = pointer.Of(int64(404))
|
||||
}
|
||||
handleStreamResultError(err, code, encoder)
|
||||
return
|
||||
}
|
||||
|
||||
// Get a connection to the server
|
||||
conn, err := a.srv.streamingRpc(srv, "Allocations.Exec")
|
||||
if err != nil {
|
||||
handleStreamResultError(err, nil, encoder)
|
||||
return
|
||||
}
|
||||
|
||||
clientConn = conn
|
||||
} else {
|
||||
stream, err := NodeStreamingRpc(state.Session, "Allocations.Exec")
|
||||
if err != nil {
|
||||
handleStreamResultError(err, nil, encoder)
|
||||
return
|
||||
}
|
||||
clientConn = stream
|
||||
}
|
||||
defer clientConn.Close()
|
||||
|
||||
// Send the request.
|
||||
outEncoder := codec.NewEncoder(clientConn, structs.MsgpackHandle)
|
||||
if err := outEncoder.Encode(args); err != nil {
|
||||
handleStreamResultError(err, nil, encoder)
|
||||
return
|
||||
}
|
||||
|
||||
structs.Bridge(conn, clientConn)
|
||||
}
|
||||
-1503
File diff suppressed because it is too large.
Load diff
@@ -1,360 +0,0 @@
|
||||
// Copyright IBM Corp. 2015, 2025
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package nomad
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
log "github.com/hashicorp/go-hclog"
|
||||
memdb "github.com/hashicorp/go-memdb"
|
||||
metrics "github.com/hashicorp/go-metrics/compat"
|
||||
|
||||
cstructs "github.com/hashicorp/nomad/client/structs"
|
||||
"github.com/hashicorp/nomad/nomad/structs"
|
||||
)
|
||||
|
||||
// ClientCSI is used to forward RPC requests to the targed Nomad client's
|
||||
// CSIController endpoint.
|
||||
type ClientCSI struct {
|
||||
srv *Server
|
||||
ctx *RPCContext
|
||||
logger log.Logger
|
||||
}
|
||||
|
||||
func NewClientCSIEndpoint(srv *Server, ctx *RPCContext) *ClientCSI {
|
||||
return &ClientCSI{srv: srv, ctx: ctx, logger: srv.logger.Named("client_csi")}
|
||||
}
|
||||
|
||||
func (a *ClientCSI) ControllerAttachVolume(args *cstructs.ClientCSIControllerAttachVolumeRequest, reply *cstructs.ClientCSIControllerAttachVolumeResponse) error {
|
||||
defer metrics.MeasureSince([]string{"nomad", "client_csi_controller", "attach_volume"}, time.Now())
|
||||
|
||||
err := a.sendCSIControllerRPC(args.PluginID,
|
||||
"CSI.ControllerAttachVolume",
|
||||
"ClientCSI.ControllerAttachVolume",
|
||||
structs.RateMetricWrite,
|
||||
args, reply)
|
||||
if err != nil {
|
||||
return fmt.Errorf("controller attach volume: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *ClientCSI) ControllerValidateVolume(args *cstructs.ClientCSIControllerValidateVolumeRequest, reply *cstructs.ClientCSIControllerValidateVolumeResponse) error {
|
||||
defer metrics.MeasureSince([]string{"nomad", "client_csi_controller", "validate_volume"}, time.Now())
|
||||
|
||||
err := a.sendCSIControllerRPC(args.PluginID,
|
||||
"CSI.ControllerValidateVolume",
|
||||
"ClientCSI.ControllerValidateVolume",
|
||||
structs.RateMetricWrite,
|
||||
args, reply)
|
||||
if err != nil {
|
||||
return fmt.Errorf("controller validate volume: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *ClientCSI) ControllerDetachVolume(args *cstructs.ClientCSIControllerDetachVolumeRequest, reply *cstructs.ClientCSIControllerDetachVolumeResponse) error {
|
||||
defer metrics.MeasureSince([]string{"nomad", "client_csi_controller", "detach_volume"}, time.Now())
|
||||
|
||||
err := a.sendCSIControllerRPC(args.PluginID,
|
||||
"CSI.ControllerDetachVolume",
|
||||
"ClientCSI.ControllerDetachVolume",
|
||||
structs.RateMetricWrite,
|
||||
args, reply)
|
||||
if err != nil {
|
||||
return fmt.Errorf("controller detach volume: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *ClientCSI) ControllerCreateVolume(args *cstructs.ClientCSIControllerCreateVolumeRequest, reply *cstructs.ClientCSIControllerCreateVolumeResponse) error {
|
||||
defer metrics.MeasureSince([]string{"nomad", "client_csi_controller", "create_volume"}, time.Now())
|
||||
|
||||
err := a.sendCSIControllerRPC(args.PluginID,
|
||||
"CSI.ControllerCreateVolume",
|
||||
"ClientCSI.ControllerCreateVolume",
|
||||
structs.RateMetricWrite,
|
||||
args, reply)
|
||||
if err != nil {
|
||||
return fmt.Errorf("controller create volume: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *ClientCSI) ControllerExpandVolume(args *cstructs.ClientCSIControllerExpandVolumeRequest, reply *cstructs.ClientCSIControllerExpandVolumeResponse) error {
|
||||
defer metrics.MeasureSince([]string{"nomad", "client_csi_controller", "expand_volume"}, time.Now())
|
||||
|
||||
err := a.sendCSIControllerRPC(args.PluginID,
|
||||
"CSI.ControllerExpandVolume",
|
||||
"ClientCSI.ControllerExpandVolume",
|
||||
structs.RateMetricWrite,
|
||||
args, reply)
|
||||
if err != nil {
|
||||
return fmt.Errorf("controller expand volume: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *ClientCSI) ControllerDeleteVolume(args *cstructs.ClientCSIControllerDeleteVolumeRequest, reply *cstructs.ClientCSIControllerDeleteVolumeResponse) error {
|
||||
defer metrics.MeasureSince([]string{"nomad", "client_csi_controller", "delete_volume"}, time.Now())
|
||||
|
||||
err := a.sendCSIControllerRPC(args.PluginID,
|
||||
"CSI.ControllerDeleteVolume",
|
||||
"ClientCSI.ControllerDeleteVolume",
|
||||
structs.RateMetricWrite,
|
||||
args, reply)
|
||||
if err != nil {
|
||||
return fmt.Errorf("controller delete volume: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *ClientCSI) ControllerListVolumes(args *cstructs.ClientCSIControllerListVolumesRequest, reply *cstructs.ClientCSIControllerListVolumesResponse) error {
|
||||
defer metrics.MeasureSince([]string{"nomad", "client_csi_controller", "list_volumes"}, time.Now())
|
||||
|
||||
err := a.sendCSIControllerRPC(args.PluginID,
|
||||
"CSI.ControllerListVolumes",
|
||||
"ClientCSI.ControllerListVolumes",
|
||||
structs.RateMetricList,
|
||||
args, reply)
|
||||
if err != nil {
|
||||
return fmt.Errorf("controller list volumes: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *ClientCSI) ControllerCreateSnapshot(args *cstructs.ClientCSIControllerCreateSnapshotRequest, reply *cstructs.ClientCSIControllerCreateSnapshotResponse) error {
|
||||
defer metrics.MeasureSince([]string{"nomad", "client_csi_controller", "create_snapshot"}, time.Now())
|
||||
|
||||
err := a.sendCSIControllerRPC(args.PluginID,
|
||||
"CSI.ControllerCreateSnapshot",
|
||||
"ClientCSI.ControllerCreateSnapshot",
|
||||
structs.RateMetricWrite,
|
||||
args, reply)
|
||||
if err != nil {
|
||||
return fmt.Errorf("controller create snapshot: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *ClientCSI) ControllerDeleteSnapshot(args *cstructs.ClientCSIControllerDeleteSnapshotRequest, reply *cstructs.ClientCSIControllerDeleteSnapshotResponse) error {
|
||||
defer metrics.MeasureSince([]string{"nomad", "client_csi_controller", "delete_snapshot"}, time.Now())
|
||||
|
||||
err := a.sendCSIControllerRPC(args.PluginID,
|
||||
"CSI.ControllerDeleteSnapshot",
|
||||
"ClientCSI.ControllerDeleteSnapshot",
|
||||
structs.RateMetricWrite,
|
||||
args, reply)
|
||||
if err != nil {
|
||||
return fmt.Errorf("controller delete snapshot: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *ClientCSI) ControllerListSnapshots(args *cstructs.ClientCSIControllerListSnapshotsRequest, reply *cstructs.ClientCSIControllerListSnapshotsResponse) error {
|
||||
defer metrics.MeasureSince([]string{"nomad", "client_csi_controller", "list_snapshots"}, time.Now())
|
||||
|
||||
err := a.sendCSIControllerRPC(args.PluginID,
|
||||
"CSI.ControllerListSnapshots",
|
||||
"ClientCSI.ControllerListSnapshots",
|
||||
structs.RateMetricList,
|
||||
args, reply)
|
||||
if err != nil {
|
||||
return fmt.Errorf("controller list snapshots: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *ClientCSI) sendCSIControllerRPC(pluginID, method, fwdMethod, op string, args cstructs.CSIControllerRequest, reply interface{}) error {
|
||||
|
||||
// client requests aren't RequestWithIdentity, so we use a placeholder here
|
||||
// to populate the identity data for metrics
|
||||
identityReq := &structs.GenericRequest{}
|
||||
|
||||
aclObj, err := a.srv.AuthenticateServerOnly(a.ctx, identityReq)
|
||||
a.srv.MeasureRPCRate("client_csi", op, identityReq)
|
||||
|
||||
if err != nil || !aclObj.AllowServerOp() {
|
||||
return structs.ErrPermissionDenied
|
||||
}
|
||||
|
||||
clientIDs, err := a.clientIDsForController(pluginID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, clientID := range clientIDs {
|
||||
args.SetControllerNodeID(clientID)
|
||||
|
||||
state, ok := a.srv.getNodeConn(clientID)
|
||||
if !ok {
|
||||
return findNodeConnAndForward(a.srv,
|
||||
clientID, fwdMethod, args, reply)
|
||||
}
|
||||
|
||||
err = NodeRpc(state.Session, method, args, reply)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if a.isRetryable(err) {
|
||||
a.logger.Debug("failed to reach controller on client",
|
||||
"nodeID", clientID, "error", err)
|
||||
continue
|
||||
}
|
||||
return err
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// we can retry the same RPC on a different controller in the cases where the
|
||||
// client has stopped and been GC'd, or where the controller has stopped but
|
||||
// we don't have the fingerprint update yet
|
||||
func (a *ClientCSI) isRetryable(err error) bool {
|
||||
// TODO: msgpack-rpc mangles the error so we lose the wrapping,
|
||||
// but if that can be fixed upstream we should use that here instead
|
||||
return strings.Contains(err.Error(), "CSI client error (retryable)") ||
|
||||
strings.Contains(err.Error(), "Unknown node")
|
||||
}
|
||||
|
||||
func (a *ClientCSI) NodeDetachVolume(args *cstructs.ClientCSINodeDetachVolumeRequest, reply *cstructs.ClientCSINodeDetachVolumeResponse) error {
|
||||
defer metrics.MeasureSince([]string{"nomad", "client_csi_node", "detach_volume"}, time.Now())
|
||||
return a.sendCSINodeRPC(
|
||||
args.NodeID,
|
||||
"CSI.NodeDetachVolume",
|
||||
"ClientCSI.NodeDetachVolume",
|
||||
structs.RateMetricWrite,
|
||||
args,
|
||||
reply,
|
||||
)
|
||||
}
|
||||
|
||||
func (a *ClientCSI) NodeExpandVolume(args *cstructs.ClientCSINodeExpandVolumeRequest, reply *cstructs.ClientCSINodeExpandVolumeResponse) error {
|
||||
defer metrics.MeasureSince([]string{"nomad", "client_csi_node", "expand_volume"}, time.Now())
|
||||
return a.sendCSINodeRPC(
|
||||
args.Claim.NodeID,
|
||||
"CSI.NodeExpandVolume",
|
||||
"ClientCSI.NodeExpandVolume",
|
||||
structs.RateMetricWrite,
|
||||
args,
|
||||
reply,
|
||||
)
|
||||
}
|
||||
|
||||
func (a *ClientCSI) sendCSINodeRPC(nodeID, method, fwdMethod, op string, args any, reply any) error {
|
||||
// client requests aren't RequestWithIdentity, so we use a placeholder here
|
||||
// to populate the identity data for metrics
|
||||
identityReq := &structs.GenericRequest{}
|
||||
aclObj, err := a.srv.AuthenticateServerOnly(a.ctx, identityReq)
|
||||
a.srv.MeasureRPCRate("client_csi", op, identityReq)
|
||||
|
||||
if err != nil || !aclObj.AllowServerOp() {
|
||||
return structs.ErrPermissionDenied
|
||||
}
|
||||
|
||||
// Make sure Node is valid and new enough to support RPC
|
||||
snap, err := a.srv.State().Snapshot()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = getNodeForRpc(snap, nodeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Get the connection to the client
|
||||
state, ok := a.srv.getNodeConn(nodeID)
|
||||
if !ok {
|
||||
return findNodeConnAndForward(a.srv, nodeID, fwdMethod, args, reply)
|
||||
}
|
||||
|
||||
// Make the RPC
|
||||
if err := NodeRpc(state.Session, method, args, reply); err != nil {
|
||||
return fmt.Errorf("%s error: %w", method, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// clientIDsForController returns a sorted list of client IDs where the
|
||||
// controller plugin is expected to be running.
|
||||
func (a *ClientCSI) clientIDsForController(pluginID string) ([]string, error) {
|
||||
|
||||
snap, err := a.srv.State().Snapshot()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if pluginID == "" {
|
||||
return nil, fmt.Errorf("missing plugin ID")
|
||||
}
|
||||
|
||||
ws := memdb.NewWatchSet()
|
||||
|
||||
// note: plugin IDs are not scoped to region but volumes are. so any Nomad
|
||||
// client we get for a controller is already in the same region for the
|
||||
// volume.
|
||||
plugin, err := snap.CSIPluginByID(ws, pluginID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error getting plugin: %s, %v", pluginID, err)
|
||||
}
|
||||
if plugin == nil {
|
||||
return nil, fmt.Errorf("plugin missing: %s", pluginID)
|
||||
}
|
||||
|
||||
clientIDs := []string{}
|
||||
|
||||
if len(plugin.Controllers) == 0 {
|
||||
return nil, fmt.Errorf("failed to find instances of controller plugin %q", pluginID)
|
||||
}
|
||||
|
||||
var merr error
|
||||
for clientID, controller := range plugin.Controllers {
|
||||
if !controller.IsController() {
|
||||
// we don't have separate types for CSIInfo depending on whether
|
||||
// it's a controller or node. this error should never make it to
|
||||
// production
|
||||
merr = errors.Join(merr, fmt.Errorf(
|
||||
"plugin instance %q is not a controller but was registered as one - this is always a bug", controller.AllocID))
|
||||
continue
|
||||
}
|
||||
|
||||
if !controller.Healthy {
|
||||
merr = errors.Join(merr, fmt.Errorf(
|
||||
"plugin instance %q is not healthy", controller.AllocID))
|
||||
continue
|
||||
}
|
||||
|
||||
node, err := getNodeForRpc(snap, clientID)
|
||||
if err != nil || node == nil {
|
||||
merr = errors.Join(merr, fmt.Errorf(
|
||||
"cannot find node %q for plugin instance %q", clientID, controller.AllocID))
|
||||
continue
|
||||
}
|
||||
|
||||
if node.Status != structs.NodeStatusReady {
|
||||
merr = errors.Join(merr, fmt.Errorf(
|
||||
"node %q for plugin instance %q is not ready", clientID, controller.AllocID))
|
||||
continue
|
||||
}
|
||||
|
||||
clientIDs = append(clientIDs, clientID)
|
||||
}
|
||||
|
||||
if len(clientIDs) == 0 {
|
||||
return nil, fmt.Errorf("failed to find clients running controller plugin %q: %v",
|
||||
pluginID, merr)
|
||||
}
|
||||
|
||||
// Many plugins don't handle concurrent requests as described in the spec,
|
||||
// and have undocumented expectations of using k8s-specific sidecars to
|
||||
// leader elect. Sort the client IDs so that we prefer sending requests to
|
||||
// the same controller to hack around this.
|
||||
slices.Sort(clientIDs)
|
||||
|
||||
return clientIDs, nil
|
||||
}
|
||||
@@ -1,558 +0,0 @@
|
||||
// Copyright IBM Corp. 2015, 2025
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package nomad
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/rpc"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
memdb "github.com/hashicorp/go-memdb"
|
||||
msgpackrpc "github.com/hashicorp/net-rpc-msgpackrpc/v2"
|
||||
"github.com/hashicorp/nomad/ci"
|
||||
"github.com/hashicorp/nomad/client"
|
||||
"github.com/hashicorp/nomad/client/config"
|
||||
cstructs "github.com/hashicorp/nomad/client/structs"
|
||||
"github.com/hashicorp/nomad/helper/uuid"
|
||||
"github.com/hashicorp/nomad/nomad/mock"
|
||||
"github.com/hashicorp/nomad/nomad/structs"
|
||||
"github.com/hashicorp/nomad/testutil"
|
||||
"github.com/shoenig/test/must"
|
||||
)
|
||||
|
||||
// MockClientCSI is a mock for the nomad.ClientCSI RPC server (see
|
||||
// nomad/client_csi_endpoint.go). This can be used with a TestRPCOnlyClient to
|
||||
// return specific plugin responses back to server RPCs for testing. Note that
|
||||
// responses that have no bodies have no "Next*Response" field and will always
|
||||
// return an empty response body.
|
||||
type MockClientCSI struct {
|
||||
NextValidateError error
|
||||
NextAttachError error
|
||||
NextAttachResponse *cstructs.ClientCSIControllerAttachVolumeResponse
|
||||
NextDetachError error
|
||||
NextCreateError error
|
||||
NextCreateResponse *cstructs.ClientCSIControllerCreateVolumeResponse
|
||||
NextDeleteError error
|
||||
NextListExternalError error
|
||||
NextListExternalResponse *cstructs.ClientCSIControllerListVolumesResponse
|
||||
NextCreateSnapshotError error
|
||||
NextCreateSnapshotResponse *cstructs.ClientCSIControllerCreateSnapshotResponse
|
||||
NextDeleteSnapshotError error
|
||||
NextListExternalSnapshotsError error
|
||||
NextListExternalSnapshotsResponse *cstructs.ClientCSIControllerListSnapshotsResponse
|
||||
NextControllerExpandVolumeError error
|
||||
NextControllerExpandVolumeResponse *cstructs.ClientCSIControllerExpandVolumeResponse
|
||||
NextNodeDetachError error
|
||||
NextNodeExpandError error
|
||||
LastNodeExpandRequest *cstructs.ClientCSINodeExpandVolumeRequest
|
||||
LastDeleteSnapshotRequest *cstructs.ClientCSIControllerDeleteSnapshotRequest
|
||||
}
|
||||
|
||||
func newMockClientCSI() *MockClientCSI {
|
||||
return &MockClientCSI{
|
||||
NextAttachResponse: &cstructs.ClientCSIControllerAttachVolumeResponse{},
|
||||
NextCreateResponse: &cstructs.ClientCSIControllerCreateVolumeResponse{},
|
||||
NextListExternalResponse: &cstructs.ClientCSIControllerListVolumesResponse{},
|
||||
NextCreateSnapshotResponse: &cstructs.ClientCSIControllerCreateSnapshotResponse{},
|
||||
NextListExternalSnapshotsResponse: &cstructs.ClientCSIControllerListSnapshotsResponse{},
|
||||
NextControllerExpandVolumeResponse: &cstructs.ClientCSIControllerExpandVolumeResponse{},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *MockClientCSI) ControllerValidateVolume(req *cstructs.ClientCSIControllerValidateVolumeRequest, resp *cstructs.ClientCSIControllerValidateVolumeResponse) error {
|
||||
return c.NextValidateError
|
||||
}
|
||||
|
||||
func (c *MockClientCSI) ControllerAttachVolume(req *cstructs.ClientCSIControllerAttachVolumeRequest, resp *cstructs.ClientCSIControllerAttachVolumeResponse) error {
|
||||
*resp = *c.NextAttachResponse
|
||||
return c.NextAttachError
|
||||
}
|
||||
|
||||
func (c *MockClientCSI) ControllerDetachVolume(req *cstructs.ClientCSIControllerDetachVolumeRequest, resp *cstructs.ClientCSIControllerDetachVolumeResponse) error {
|
||||
return c.NextDetachError
|
||||
}
|
||||
|
||||
func (c *MockClientCSI) ControllerCreateVolume(req *cstructs.ClientCSIControllerCreateVolumeRequest, resp *cstructs.ClientCSIControllerCreateVolumeResponse) error {
|
||||
*resp = *c.NextCreateResponse
|
||||
return c.NextCreateError
|
||||
}
|
||||
|
||||
func (c *MockClientCSI) ControllerDeleteVolume(req *cstructs.ClientCSIControllerDeleteVolumeRequest, resp *cstructs.ClientCSIControllerDeleteVolumeResponse) error {
|
||||
return c.NextDeleteError
|
||||
}
|
||||
|
||||
func (c *MockClientCSI) ControllerListVolumes(req *cstructs.ClientCSIControllerListVolumesRequest, resp *cstructs.ClientCSIControllerListVolumesResponse) error {
|
||||
*resp = *c.NextListExternalResponse
|
||||
return c.NextListExternalError
|
||||
}
|
||||
|
||||
func (c *MockClientCSI) ControllerCreateSnapshot(req *cstructs.ClientCSIControllerCreateSnapshotRequest, resp *cstructs.ClientCSIControllerCreateSnapshotResponse) error {
|
||||
*resp = *c.NextCreateSnapshotResponse
|
||||
return c.NextCreateSnapshotError
|
||||
}
|
||||
|
||||
func (c *MockClientCSI) ControllerDeleteSnapshot(req *cstructs.ClientCSIControllerDeleteSnapshotRequest, resp *cstructs.ClientCSIControllerDeleteSnapshotResponse) error {
|
||||
c.LastDeleteSnapshotRequest = req
|
||||
return c.NextDeleteSnapshotError
|
||||
}
|
||||
|
||||
func (c *MockClientCSI) ControllerListSnapshots(req *cstructs.ClientCSIControllerListSnapshotsRequest, resp *cstructs.ClientCSIControllerListSnapshotsResponse) error {
|
||||
*resp = *c.NextListExternalSnapshotsResponse
|
||||
return c.NextListExternalSnapshotsError
|
||||
}
|
||||
|
||||
func (c *MockClientCSI) ControllerExpandVolume(req *cstructs.ClientCSIControllerExpandVolumeRequest, resp *cstructs.ClientCSIControllerExpandVolumeResponse) error {
|
||||
*resp = *c.NextControllerExpandVolumeResponse
|
||||
return c.NextControllerExpandVolumeError
|
||||
}
|
||||
|
||||
func (c *MockClientCSI) NodeDetachVolume(req *cstructs.ClientCSINodeDetachVolumeRequest, resp *cstructs.ClientCSINodeDetachVolumeResponse) error {
|
||||
return c.NextNodeDetachError
|
||||
}
|
||||
|
||||
func (c *MockClientCSI) NodeExpandVolume(req *cstructs.ClientCSINodeExpandVolumeRequest, resp *cstructs.ClientCSINodeExpandVolumeResponse) error {
|
||||
c.LastNodeExpandRequest = req
|
||||
return c.NextNodeExpandError
|
||||
}
|
||||
|
||||
func TestClientCSIController_AttachVolume_Local(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
codec := setupLocal(t)
|
||||
|
||||
req := &cstructs.ClientCSIControllerAttachVolumeRequest{
|
||||
CSIControllerQuery: cstructs.CSIControllerQuery{PluginID: "minnie"},
|
||||
}
|
||||
|
||||
var resp structs.GenericResponse
|
||||
err := msgpackrpc.CallWithCodec(codec, "ClientCSI.ControllerAttachVolume", req, &resp)
|
||||
must.ErrorContains(t, err, "no plugins registered for type")
|
||||
}
|
||||
|
||||
func TestClientCSIController_AttachVolume_Forwarded(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
codec := setupForward(t)
|
||||
|
||||
req := &cstructs.ClientCSIControllerAttachVolumeRequest{
|
||||
CSIControllerQuery: cstructs.CSIControllerQuery{PluginID: "minnie"},
|
||||
}
|
||||
|
||||
var resp structs.GenericResponse
|
||||
err := msgpackrpc.CallWithCodec(codec, "ClientCSI.ControllerAttachVolume", req, &resp)
|
||||
must.ErrorContains(t, err, "no plugins registered for type")
|
||||
}
|
||||
|
||||
func TestClientCSIController_DetachVolume_Local(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
codec := setupLocal(t)
|
||||
|
||||
req := &cstructs.ClientCSIControllerDetachVolumeRequest{
|
||||
CSIControllerQuery: cstructs.CSIControllerQuery{PluginID: "minnie"},
|
||||
}
|
||||
|
||||
var resp structs.GenericResponse
|
||||
err := msgpackrpc.CallWithCodec(codec, "ClientCSI.ControllerDetachVolume", req, &resp)
|
||||
must.ErrorContains(t, err, "no plugins registered for type")
|
||||
}
|
||||
|
||||
func TestClientCSIController_DetachVolume_Forwarded(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
codec := setupForward(t)
|
||||
|
||||
req := &cstructs.ClientCSIControllerDetachVolumeRequest{
|
||||
CSIControllerQuery: cstructs.CSIControllerQuery{PluginID: "minnie"},
|
||||
}
|
||||
|
||||
var resp structs.GenericResponse
|
||||
err := msgpackrpc.CallWithCodec(codec, "ClientCSI.ControllerDetachVolume", req, &resp)
|
||||
must.ErrorContains(t, err, "no plugins registered for type")
|
||||
}
|
||||
|
||||
func TestClientCSIController_ValidateVolume_Local(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
codec := setupLocal(t)
|
||||
|
||||
req := &cstructs.ClientCSIControllerValidateVolumeRequest{
|
||||
VolumeID: "test",
|
||||
CSIControllerQuery: cstructs.CSIControllerQuery{PluginID: "minnie"},
|
||||
}
|
||||
|
||||
var resp structs.GenericResponse
|
||||
err := msgpackrpc.CallWithCodec(codec, "ClientCSI.ControllerValidateVolume", req, &resp)
|
||||
must.ErrorContains(t, err, "no plugins registered for type")
|
||||
}
|
||||
|
||||
func TestClientCSIController_ValidateVolume_Forwarded(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
codec := setupForward(t)
|
||||
|
||||
req := &cstructs.ClientCSIControllerValidateVolumeRequest{
|
||||
VolumeID: "test",
|
||||
CSIControllerQuery: cstructs.CSIControllerQuery{PluginID: "minnie"},
|
||||
}
|
||||
|
||||
var resp structs.GenericResponse
|
||||
err := msgpackrpc.CallWithCodec(codec, "ClientCSI.ControllerValidateVolume", req, &resp)
|
||||
must.ErrorContains(t, err, "no plugins registered for type")
|
||||
}
|
||||
|
||||
func TestClientCSIController_CreateVolume_Local(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
codec := setupLocal(t)
|
||||
|
||||
req := &cstructs.ClientCSIControllerCreateVolumeRequest{
|
||||
CSIControllerQuery: cstructs.CSIControllerQuery{PluginID: "minnie"},
|
||||
}
|
||||
|
||||
var resp structs.GenericResponse
|
||||
err := msgpackrpc.CallWithCodec(codec, "ClientCSI.ControllerCreateVolume", req, &resp)
|
||||
must.ErrorContains(t, err, "no plugins registered for type")
|
||||
}
|
||||
|
||||
func TestClientCSIController_CreateVolume_Forwarded(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
codec := setupForward(t)
|
||||
|
||||
req := &cstructs.ClientCSIControllerCreateVolumeRequest{
|
||||
CSIControllerQuery: cstructs.CSIControllerQuery{PluginID: "minnie"},
|
||||
}
|
||||
|
||||
var resp structs.GenericResponse
|
||||
err := msgpackrpc.CallWithCodec(codec, "ClientCSI.ControllerCreateVolume", req, &resp)
|
||||
must.ErrorContains(t, err, "no plugins registered for type")
|
||||
}
|
||||
|
||||
func TestClientCSIController_DeleteVolume_Local(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
codec := setupLocal(t)
|
||||
|
||||
req := &cstructs.ClientCSIControllerDeleteVolumeRequest{
|
||||
ExternalVolumeID: "test",
|
||||
CSIControllerQuery: cstructs.CSIControllerQuery{PluginID: "minnie"},
|
||||
}
|
||||
|
||||
var resp structs.GenericResponse
|
||||
err := msgpackrpc.CallWithCodec(codec, "ClientCSI.ControllerDeleteVolume", req, &resp)
|
||||
must.ErrorContains(t, err, "no plugins registered for type")
|
||||
}
|
||||
|
||||
func TestClientCSIController_DeleteVolume_Forwarded(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
codec := setupForward(t)
|
||||
|
||||
req := &cstructs.ClientCSIControllerDeleteVolumeRequest{
|
||||
ExternalVolumeID: "test",
|
||||
CSIControllerQuery: cstructs.CSIControllerQuery{PluginID: "minnie"},
|
||||
}
|
||||
|
||||
var resp structs.GenericResponse
|
||||
err := msgpackrpc.CallWithCodec(codec, "ClientCSI.ControllerDeleteVolume", req, &resp)
|
||||
must.ErrorContains(t, err, "no plugins registered for type")
|
||||
}
|
||||
|
||||
func TestClientCSIController_ListVolumes_Local(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
codec := setupLocal(t)
|
||||
|
||||
req := &cstructs.ClientCSIControllerListVolumesRequest{
|
||||
CSIControllerQuery: cstructs.CSIControllerQuery{PluginID: "minnie"},
|
||||
}
|
||||
|
||||
var resp structs.GenericResponse
|
||||
err := msgpackrpc.CallWithCodec(codec, "ClientCSI.ControllerListVolumes", req, &resp)
|
||||
must.ErrorContains(t, err, "no plugins registered for type")
|
||||
}
|
||||
|
||||
func TestClientCSIController_ListVolumes_Forwarded(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
codec := setupForward(t)
|
||||
|
||||
req := &cstructs.ClientCSIControllerListVolumesRequest{
|
||||
CSIControllerQuery: cstructs.CSIControllerQuery{PluginID: "minnie"},
|
||||
}
|
||||
|
||||
var resp structs.GenericResponse
|
||||
err := msgpackrpc.CallWithCodec(codec, "ClientCSI.ControllerListVolumes", req, &resp)
|
||||
must.ErrorContains(t, err, "no plugins registered for type")
|
||||
}
|
||||
|
||||
func TestClientCSIController_CreateSnapshot_Local(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
codec := setupLocal(t)
|
||||
|
||||
req := &cstructs.ClientCSIControllerCreateSnapshotRequest{
|
||||
CSIControllerQuery: cstructs.CSIControllerQuery{PluginID: "minnie"},
|
||||
}
|
||||
|
||||
var resp structs.GenericResponse
|
||||
err := msgpackrpc.CallWithCodec(codec, "ClientCSI.ControllerCreateSnapshot", req, &resp)
|
||||
must.ErrorContains(t, err, "no plugins registered for type")
|
||||
}
|
||||
|
||||
func TestClientCSIController_CreateSnapshot_Forwarded(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
codec := setupForward(t)
|
||||
|
||||
req := &cstructs.ClientCSIControllerCreateSnapshotRequest{
|
||||
CSIControllerQuery: cstructs.CSIControllerQuery{PluginID: "minnie"},
|
||||
}
|
||||
|
||||
var resp structs.GenericResponse
|
||||
err := msgpackrpc.CallWithCodec(codec, "ClientCSI.ControllerCreateSnapshot", req, &resp)
|
||||
must.ErrorContains(t, err, "no plugins registered for type")
|
||||
}
|
||||
|
||||
func TestClientCSIController_DeleteSnapshot_Local(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
codec := setupLocal(t)
|
||||
|
||||
req := &cstructs.ClientCSIControllerDeleteSnapshotRequest{
|
||||
ID: "test",
|
||||
CSIControllerQuery: cstructs.CSIControllerQuery{PluginID: "minnie"},
|
||||
}
|
||||
|
||||
var resp structs.GenericResponse
|
||||
err := msgpackrpc.CallWithCodec(codec, "ClientCSI.ControllerDeleteSnapshot", req, &resp)
|
||||
must.ErrorContains(t, err, "no plugins registered for type")
|
||||
}
|
||||
|
||||
func TestClientCSIController_DeleteSnapshot_Forwarded(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
codec := setupForward(t)
|
||||
|
||||
req := &cstructs.ClientCSIControllerDeleteSnapshotRequest{
|
||||
ID: "test",
|
||||
CSIControllerQuery: cstructs.CSIControllerQuery{PluginID: "minnie"},
|
||||
}
|
||||
|
||||
var resp structs.GenericResponse
|
||||
err := msgpackrpc.CallWithCodec(codec, "ClientCSI.ControllerDeleteSnapshot", req, &resp)
|
||||
must.ErrorContains(t, err, "no plugins registered for type")
|
||||
}
|
||||
|
||||
func TestClientCSIController_ListSnapshots_Local(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
codec := setupLocal(t)
|
||||
|
||||
req := &cstructs.ClientCSIControllerListSnapshotsRequest{
|
||||
CSIControllerQuery: cstructs.CSIControllerQuery{PluginID: "minnie"},
|
||||
}
|
||||
|
||||
var resp structs.GenericResponse
|
||||
err := msgpackrpc.CallWithCodec(codec, "ClientCSI.ControllerListSnapshots", req, &resp)
|
||||
must.ErrorContains(t, err, "no plugins registered for type")
|
||||
}
|
||||
|
||||
func TestClientCSIController_ListSnapshots_Forwarded(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
codec := setupForward(t)
|
||||
|
||||
req := &cstructs.ClientCSIControllerListSnapshotsRequest{
|
||||
CSIControllerQuery: cstructs.CSIControllerQuery{PluginID: "minnie"},
|
||||
}
|
||||
|
||||
var resp structs.GenericResponse
|
||||
err := msgpackrpc.CallWithCodec(codec, "ClientCSI.ControllerListSnapshots", req, &resp)
|
||||
must.ErrorContains(t, err, "no plugins registered for type")
|
||||
}
|
||||
|
||||
func TestClientCSI_NodeForControllerPlugin(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
srv, shutdown := TestServer(t, func(c *Config) {})
|
||||
testutil.WaitForLeader(t, srv.RPC)
|
||||
defer shutdown()
|
||||
|
||||
plugins := map[string]*structs.CSIInfo{
|
||||
"minnie": {PluginID: "minnie",
|
||||
Healthy: true,
|
||||
ControllerInfo: &structs.CSIControllerInfo{},
|
||||
NodeInfo: &structs.CSINodeInfo{},
|
||||
RequiresControllerPlugin: true,
|
||||
},
|
||||
}
|
||||
state := srv.fsm.State()
|
||||
|
||||
node1 := mock.Node()
|
||||
node1.Attributes["nomad.version"] = "0.11.0" // client RPCs not supported on early versions
|
||||
node1.CSIControllerPlugins = plugins
|
||||
node2 := mock.Node()
|
||||
node2.CSIControllerPlugins = plugins
|
||||
node2.ID = uuid.Generate()
|
||||
node3 := mock.Node()
|
||||
node3.ID = uuid.Generate()
|
||||
|
||||
err := state.UpsertNode(structs.MsgTypeTestSetup, 1002, node1)
|
||||
must.NoError(t, err)
|
||||
err = state.UpsertNode(structs.MsgTypeTestSetup, 1003, node2)
|
||||
must.NoError(t, err)
|
||||
err = state.UpsertNode(structs.MsgTypeTestSetup, 1004, node3)
|
||||
must.NoError(t, err)
|
||||
|
||||
ws := memdb.NewWatchSet()
|
||||
|
||||
plugin, err := state.CSIPluginByID(ws, "minnie")
|
||||
must.NoError(t, err)
|
||||
|
||||
clientCSI := NewClientCSIEndpoint(srv, nil)
|
||||
nodeIDs, err := clientCSI.clientIDsForController(plugin.ID)
|
||||
must.NoError(t, err)
|
||||
must.Len(t, 1, nodeIDs)
|
||||
// only node1 has both the controller and a recent Nomad version
|
||||
must.Eq(t, nodeIDs[0], node1.ID)
|
||||
}
|
||||
|
||||
// sets up a pair of servers, each with one client, and registers a plugin to the clients.
|
||||
// returns a RPC client to the leader and a cleanup function.
|
||||
func setupForward(t *testing.T) rpc.ClientCodec {
|
||||
|
||||
s1, cleanupS1 := TestServer(t, func(c *Config) { c.BootstrapExpect = 2 })
|
||||
s2, cleanupS2 := TestServer(t, func(c *Config) { c.BootstrapExpect = 2 })
|
||||
t.Cleanup(cleanupS1)
|
||||
t.Cleanup(cleanupS2)
|
||||
TestJoin(t, s1, s2)
|
||||
|
||||
testutil.WaitForLeader(t, s1.RPC)
|
||||
testutil.WaitForLeader(t, s2.RPC)
|
||||
codec := rpcClient(t, s1)
|
||||
|
||||
c1, cleanupC1 := client.TestClient(t, func(c *config.Config) {
|
||||
c.Servers = []string{s1.config.RPCAddr.String()}
|
||||
})
|
||||
t.Cleanup(func() { cleanupC1() })
|
||||
|
||||
// Wait for client initialization
|
||||
select {
|
||||
case <-c1.Ready():
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("client timedout on initialize")
|
||||
}
|
||||
|
||||
c2, cleanupC2 := client.TestClient(t, func(c *config.Config) {
|
||||
c.Servers = []string{s2.config.RPCAddr.String()}
|
||||
})
|
||||
t.Cleanup(func() { cleanupC2() })
|
||||
select {
|
||||
case <-c2.Ready():
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("client timedout on initialize")
|
||||
}
|
||||
|
||||
s1.nodeConnsLock.Lock()
|
||||
delete(s1.nodeConns, c2.NodeID())
|
||||
s1.nodeConnsLock.Unlock()
|
||||
|
||||
s2.nodeConnsLock.Lock()
|
||||
delete(s2.nodeConns, c1.NodeID())
|
||||
s2.nodeConnsLock.Unlock()
|
||||
|
||||
waitForNodes(t, s2, 1, 2)
|
||||
|
||||
plugins := map[string]*structs.CSIInfo{
|
||||
"minnie": {PluginID: "minnie",
|
||||
Healthy: true,
|
||||
ControllerInfo: &structs.CSIControllerInfo{},
|
||||
NodeInfo: &structs.CSINodeInfo{},
|
||||
RequiresControllerPlugin: true,
|
||||
},
|
||||
}
|
||||
|
||||
// update w/ plugin
|
||||
node1 := c1.Node()
|
||||
node1.Attributes["nomad.version"] = "0.11.0" // client RPCs not supported on early versions
|
||||
node1.CSIControllerPlugins = plugins
|
||||
|
||||
s1.fsm.state.UpsertNode(structs.MsgTypeTestSetup, 1000, node1)
|
||||
|
||||
return codec
|
||||
}
|
||||
|
||||
// sets up a single server with a client, and registers a plugin to the client.
|
||||
func setupLocal(t *testing.T) rpc.ClientCodec {
|
||||
var err error
|
||||
s1, cleanupS1 := TestServer(t, func(c *Config) { c.BootstrapExpect = 1 })
|
||||
t.Cleanup(cleanupS1)
|
||||
|
||||
testutil.WaitForLeader(t, s1.RPC)
|
||||
testutil.WaitForKeyring(t, s1.RPC, s1.config.Region)
|
||||
codec := rpcClient(t, s1)
|
||||
|
||||
mockCSI := newMockClientCSI()
|
||||
mockCSI.NextValidateError = fmt.Errorf("no plugins registered for type")
|
||||
mockCSI.NextAttachError = fmt.Errorf("no plugins registered for type")
|
||||
mockCSI.NextDetachError = fmt.Errorf("no plugins registered for type")
|
||||
mockCSI.NextCreateError = fmt.Errorf("no plugins registered for type")
|
||||
mockCSI.NextDeleteError = fmt.Errorf("no plugins registered for type")
|
||||
mockCSI.NextListExternalError = fmt.Errorf("no plugins registered for type")
|
||||
mockCSI.NextCreateSnapshotError = fmt.Errorf("no plugins registered for type")
|
||||
mockCSI.NextDeleteSnapshotError = fmt.Errorf("no plugins registered for type")
|
||||
mockCSI.NextListExternalSnapshotsError = fmt.Errorf("no plugins registered for type")
|
||||
|
||||
c1, cleanupC1 := client.TestRPCOnlyClient(t, nil, s1.config.RPCAddr,
|
||||
map[string]interface{}{"CSI": mockCSI},
|
||||
)
|
||||
t.Cleanup(cleanupC1)
|
||||
|
||||
node1 := c1.UpdateConfig(func(c *config.Config) {
|
||||
c.Node.Attributes["nomad.version"] = "0.11.0" // client RPCs not supported on early versions
|
||||
}).Node
|
||||
|
||||
req := &structs.NodeRegisterRequest{
|
||||
Node: node1,
|
||||
WriteRequest: structs.WriteRequest{Region: "global"},
|
||||
}
|
||||
var resp structs.NodeUpdateResponse
|
||||
err = c1.RPC("Node.Register", req, &resp)
|
||||
must.NoError(t, err, must.Sprint("could not register client node"))
|
||||
|
||||
waitForNodes(t, s1, 1, 1)
|
||||
|
||||
plugins := map[string]*structs.CSIInfo{
|
||||
"minnie": {PluginID: "minnie",
|
||||
Healthy: true,
|
||||
ControllerInfo: &structs.CSIControllerInfo{},
|
||||
NodeInfo: &structs.CSINodeInfo{},
|
||||
RequiresControllerPlugin: true,
|
||||
},
|
||||
}
|
||||
|
||||
// update w/ plugin
|
||||
node1 = c1.UpdateConfig(func(c *config.Config) {
|
||||
c.Node.CSIControllerPlugins = plugins
|
||||
}).Node
|
||||
s1.fsm.state.UpsertNode(structs.MsgTypeTestSetup, 1000, node1)
|
||||
|
||||
return codec
|
||||
}
|
||||
|
||||
// waitForNodes waits until the server is connected to connectedNodes
|
||||
// clients and totalNodes clients are in the state store
|
||||
func waitForNodes(t *testing.T, s *Server, connectedNodes, totalNodes int) {
|
||||
codec := rpcClient(t, s)
|
||||
|
||||
testutil.WaitForResult(func() (bool, error) {
|
||||
connNodes := s.connectedNodes()
|
||||
if len(connNodes) != connectedNodes {
|
||||
return false, fmt.Errorf("expected %d connected nodes but found %d", connectedNodes, len(connNodes))
|
||||
}
|
||||
|
||||
get := &structs.NodeListRequest{
|
||||
QueryOptions: structs.QueryOptions{Region: "global"},
|
||||
}
|
||||
var resp structs.NodeListResponse
|
||||
err := msgpackrpc.CallWithCodec(codec, "Node.List", get, &resp)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to list nodes: %v", err)
|
||||
}
|
||||
if len(resp.Nodes) != totalNodes {
|
||||
return false, fmt.Errorf("expected %d total nodes but found %d", totalNodes, len(resp.Nodes))
|
||||
}
|
||||
return true, nil
|
||||
}, func(err error) {
|
||||
must.NoError(t, err)
|
||||
})
|
||||
}
|
||||
@@ -1,475 +0,0 @@
|
||||
// Copyright IBM Corp. 2015, 2025
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package nomad
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
log "github.com/hashicorp/go-hclog"
|
||||
metrics "github.com/hashicorp/go-metrics/compat"
|
||||
cstructs "github.com/hashicorp/nomad/client/structs"
|
||||
"github.com/hashicorp/nomad/helper/pointer"
|
||||
|
||||
"github.com/hashicorp/go-msgpack/v2/codec"
|
||||
"github.com/hashicorp/nomad/acl"
|
||||
"github.com/hashicorp/nomad/nomad/structs"
|
||||
)
|
||||
|
||||
// FileSystem endpoint is used for accessing the logs and filesystem of
|
||||
// allocations from a Node.
|
||||
type FileSystem struct {
|
||||
srv *Server
|
||||
logger log.Logger
|
||||
}
|
||||
|
||||
func NewFileSystemEndpoint(srv *Server) *FileSystem {
|
||||
return &FileSystem{srv: srv, logger: srv.logger.Named("client_fs")}
|
||||
}
|
||||
|
||||
func (f *FileSystem) register() {
|
||||
f.srv.streamingRpcs.Register("FileSystem.Logs", f.logs)
|
||||
f.srv.streamingRpcs.Register("FileSystem.Stream", f.stream)
|
||||
}
|
||||
|
||||
// handleStreamResultError is a helper for sending an error with a potential
|
||||
// error code. The transmission of the error is ignored if the error has been
|
||||
// generated by the closing of the underlying transport.
|
||||
func handleStreamResultError(err error, code *int64, encoder *codec.Encoder) {
|
||||
// Nothing to do as the conn is closed
|
||||
if err == io.EOF || strings.Contains(err.Error(), "closed") {
|
||||
return
|
||||
}
|
||||
|
||||
// Attempt to send the error
|
||||
encoder.Encode(&cstructs.StreamErrWrapper{
|
||||
Error: cstructs.NewRpcError(err, code),
|
||||
})
|
||||
}
|
||||
|
||||
// forwardRegionStreamingRpc is used to make a streaming RPC to a different
|
||||
// region. It looks up the allocation in the remote region to determine what
|
||||
// remote server can route the request.
|
||||
func forwardRegionStreamingRpc(fsrv *Server, conn io.ReadWriteCloser,
|
||||
encoder *codec.Encoder, args interface{}, method, allocID string, qo *structs.QueryOptions) {
|
||||
// Request the allocation from the target region
|
||||
allocReq := &structs.AllocSpecificRequest{
|
||||
AllocID: allocID,
|
||||
QueryOptions: *qo,
|
||||
}
|
||||
var allocResp structs.SingleAllocResponse
|
||||
if err := fsrv.forwardRegion(qo.RequestRegion(), "Alloc.GetAlloc", allocReq, &allocResp); err != nil {
|
||||
handleStreamResultError(err, nil, encoder)
|
||||
return
|
||||
}
|
||||
|
||||
if allocResp.Alloc == nil {
|
||||
handleStreamResultError(structs.NewErrUnknownAllocation(allocID), pointer.Of(int64(404)), encoder)
|
||||
return
|
||||
}
|
||||
|
||||
// Determine the Server that has a connection to the node.
|
||||
srv, err := fsrv.serverWithNodeConn(allocResp.Alloc.NodeID, qo.RequestRegion())
|
||||
if err != nil {
|
||||
var code *int64
|
||||
if structs.IsErrNoNodeConn(err) {
|
||||
code = pointer.Of(int64(404))
|
||||
}
|
||||
handleStreamResultError(err, code, encoder)
|
||||
return
|
||||
}
|
||||
|
||||
// Get a connection to the server
|
||||
srvConn, err := fsrv.streamingRpc(srv, method)
|
||||
if err != nil {
|
||||
handleStreamResultError(err, nil, encoder)
|
||||
return
|
||||
}
|
||||
defer srvConn.Close()
|
||||
|
||||
// Send the request.
|
||||
outEncoder := codec.NewEncoder(srvConn, structs.MsgpackHandle)
|
||||
if err := outEncoder.Encode(args); err != nil {
|
||||
handleStreamResultError(err, nil, encoder)
|
||||
return
|
||||
}
|
||||
|
||||
structs.Bridge(conn, srvConn)
|
||||
}
|
||||
|
||||
// List is used to list the contents of an allocation's directory.
|
||||
func (f *FileSystem) List(args *cstructs.FsListRequest, reply *cstructs.FsListResponse) error {
|
||||
// We only allow stale reads since the only potentially stale information is
|
||||
// the Node registration and the cost is fairly high for adding another hope
|
||||
// in the forwarding chain.
|
||||
args.QueryOptions.AllowStale = true
|
||||
|
||||
authErr := f.srv.Authenticate(nil, args)
|
||||
|
||||
// Potentially forward to a different region.
|
||||
if done, err := f.srv.forward("FileSystem.List", args, args, reply); done {
|
||||
return err
|
||||
}
|
||||
f.srv.MeasureRPCRate("file_system", structs.RateMetricList, args)
|
||||
if authErr != nil {
|
||||
return structs.ErrPermissionDenied
|
||||
}
|
||||
defer metrics.MeasureSince([]string{"nomad", "file_system", "list"}, time.Now())
|
||||
|
||||
// Verify the arguments.
|
||||
if args.AllocID == "" {
|
||||
return errors.New("missing allocation ID")
|
||||
}
|
||||
|
||||
// Lookup the allocation
|
||||
snap, err := f.srv.State().Snapshot()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
alloc, err := getAlloc(snap, args.AllocID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check namespace filesystem read permissions
|
||||
allowNsOp := acl.NamespaceValidator(acl.NamespaceCapabilityReadFS)
|
||||
aclObj, err := f.srv.ResolveACL(args)
|
||||
if err != nil {
|
||||
return err
|
||||
} else if !allowNsOp(aclObj, alloc.Namespace) {
|
||||
return structs.ErrPermissionDenied
|
||||
}
|
||||
|
||||
// Make sure Node is valid and new enough to support RPC
|
||||
_, err = getNodeForRpc(snap, alloc.NodeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Get the connection to the client
|
||||
state, ok := f.srv.getNodeConn(alloc.NodeID)
|
||||
if !ok {
|
||||
return findNodeConnAndForward(f.srv, alloc.NodeID, "FileSystem.List", args, reply)
|
||||
}
|
||||
|
||||
// Make the RPC
|
||||
return NodeRpc(state.Session, "FileSystem.List", args, reply)
|
||||
}
|
||||
|
||||
// Stat is used to stat a file in the allocation's directory.
|
||||
func (f *FileSystem) Stat(args *cstructs.FsStatRequest, reply *cstructs.FsStatResponse) error {
|
||||
// We only allow stale reads since the only potentially stale information is
|
||||
// the Node registration and the cost is fairly high for adding another hope
|
||||
// in the forwarding chain.
|
||||
args.QueryOptions.AllowStale = true
|
||||
|
||||
authErr := f.srv.Authenticate(nil, args)
|
||||
|
||||
// Potentially forward to a different region.
|
||||
if done, err := f.srv.forward("FileSystem.Stat", args, args, reply); done {
|
||||
return err
|
||||
}
|
||||
f.srv.MeasureRPCRate("file_system", structs.RateMetricRead, args)
|
||||
if authErr != nil {
|
||||
return structs.ErrPermissionDenied
|
||||
}
|
||||
defer metrics.MeasureSince([]string{"nomad", "file_system", "stat"}, time.Now())
|
||||
|
||||
// Verify the arguments.
|
||||
if args.AllocID == "" {
|
||||
return errors.New("missing allocation ID")
|
||||
}
|
||||
|
||||
// Lookup the allocation
|
||||
snap, err := f.srv.State().Snapshot()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
alloc, err := getAlloc(snap, args.AllocID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check filesystem read permissions
|
||||
if aclObj, err := f.srv.ResolveACL(args); err != nil {
|
||||
return err
|
||||
} else if !aclObj.AllowNsOp(alloc.Namespace, acl.NamespaceCapabilityReadFS) {
|
||||
return structs.ErrPermissionDenied
|
||||
}
|
||||
|
||||
// Make sure Node is valid and new enough to support RPC
|
||||
_, err = getNodeForRpc(snap, alloc.NodeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Get the connection to the client
|
||||
state, ok := f.srv.getNodeConn(alloc.NodeID)
|
||||
if !ok {
|
||||
return findNodeConnAndForward(f.srv, alloc.NodeID, "FileSystem.Stat", args, reply)
|
||||
}
|
||||
|
||||
// Make the RPC
|
||||
return NodeRpc(state.Session, "FileSystem.Stat", args, reply)
|
||||
}
|
||||
|
||||
// stream is is used to stream the contents of file in an allocation's
|
||||
// directory.
|
||||
func (f *FileSystem) stream(conn io.ReadWriteCloser) {
|
||||
defer conn.Close()
|
||||
defer metrics.MeasureSince([]string{"nomad", "file_system", "stream"}, time.Now())
|
||||
|
||||
// Decode the arguments
|
||||
var args cstructs.FsStreamRequest
|
||||
decoder := codec.NewDecoder(conn, structs.MsgpackHandle)
|
||||
encoder := codec.NewEncoder(conn, structs.MsgpackHandle)
|
||||
|
||||
if err := decoder.Decode(&args); err != nil {
|
||||
handleStreamResultError(err, pointer.Of(int64(500)), encoder)
|
||||
return
|
||||
}
|
||||
|
||||
authErr := f.srv.Authenticate(nil, &args)
|
||||
|
||||
// Check if we need to forward to a different region
|
||||
if r := args.RequestRegion(); r != f.srv.Region() {
|
||||
forwardRegionStreamingRpc(f.srv, conn, encoder, &args, "FileSystem.Stream",
|
||||
args.AllocID, &args.QueryOptions)
|
||||
return
|
||||
}
|
||||
f.srv.MeasureRPCRate("file_system", structs.RateMetricRead, &args)
|
||||
if authErr != nil {
|
||||
handleStreamResultError(structs.ErrPermissionDenied, nil, encoder)
|
||||
return
|
||||
}
|
||||
|
||||
// Verify the arguments.
|
||||
if args.AllocID == "" {
|
||||
handleStreamResultError(errors.New("missing AllocID"), pointer.Of(int64(400)), encoder)
|
||||
return
|
||||
}
|
||||
|
||||
// Retrieve the allocation
|
||||
snap, err := f.srv.State().Snapshot()
|
||||
if err != nil {
|
||||
handleStreamResultError(err, nil, encoder)
|
||||
return
|
||||
}
|
||||
|
||||
alloc, err := getAlloc(snap, args.AllocID)
|
||||
if structs.IsErrUnknownAllocation(err) {
|
||||
handleStreamResultError(structs.NewErrUnknownAllocation(args.AllocID), pointer.Of(int64(404)), encoder)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
handleStreamResultError(err, nil, encoder)
|
||||
return
|
||||
}
|
||||
|
||||
// Check namespace read-fs permissions.
|
||||
if aclObj, err := f.srv.ResolveACL(&args); err != nil {
|
||||
handleStreamResultError(err, nil, encoder)
|
||||
return
|
||||
} else if !aclObj.AllowNsOp(alloc.Namespace, acl.NamespaceCapabilityReadFS) {
|
||||
handleStreamResultError(structs.ErrPermissionDenied, nil, encoder)
|
||||
return
|
||||
}
|
||||
|
||||
nodeID := alloc.NodeID
|
||||
|
||||
// Make sure Node is valid and new enough to support RPC
|
||||
node, err := snap.NodeByID(nil, nodeID)
|
||||
if err != nil {
|
||||
handleStreamResultError(err, pointer.Of(int64(500)), encoder)
|
||||
return
|
||||
}
|
||||
|
||||
if node == nil {
|
||||
err := fmt.Errorf("Unknown node %q", nodeID)
|
||||
handleStreamResultError(err, pointer.Of(int64(400)), encoder)
|
||||
return
|
||||
}
|
||||
|
||||
if err := nodeSupportsRpc(node); err != nil {
|
||||
handleStreamResultError(err, pointer.Of(int64(400)), encoder)
|
||||
return
|
||||
}
|
||||
|
||||
// Get the connection to the client either by forwarding to another server
|
||||
// or creating a direct stream
|
||||
var clientConn net.Conn
|
||||
state, ok := f.srv.getNodeConn(nodeID)
|
||||
if !ok {
|
||||
// Determine the Server that has a connection to the node.
|
||||
srv, err := f.srv.serverWithNodeConn(nodeID, f.srv.Region())
|
||||
if err != nil {
|
||||
var code *int64
|
||||
if structs.IsErrNoNodeConn(err) {
|
||||
code = pointer.Of(int64(404))
|
||||
}
|
||||
handleStreamResultError(err, code, encoder)
|
||||
return
|
||||
}
|
||||
|
||||
// Get a connection to the server
|
||||
conn, err := f.srv.streamingRpc(srv, "FileSystem.Stream")
|
||||
if err != nil {
|
||||
handleStreamResultError(err, nil, encoder)
|
||||
return
|
||||
}
|
||||
|
||||
clientConn = conn
|
||||
} else {
|
||||
stream, err := NodeStreamingRpc(state.Session, "FileSystem.Stream")
|
||||
if err != nil {
|
||||
handleStreamResultError(err, nil, encoder)
|
||||
return
|
||||
}
|
||||
clientConn = stream
|
||||
}
|
||||
defer clientConn.Close()
|
||||
|
||||
// Send the request.
|
||||
outEncoder := codec.NewEncoder(clientConn, structs.MsgpackHandle)
|
||||
if err := outEncoder.Encode(args); err != nil {
|
||||
handleStreamResultError(err, nil, encoder)
|
||||
return
|
||||
}
|
||||
|
||||
structs.Bridge(conn, clientConn)
|
||||
}
|
||||
|
||||
// logs is used to access an task's logs for a given allocation
|
||||
func (f *FileSystem) logs(conn io.ReadWriteCloser) {
|
||||
defer conn.Close()
|
||||
defer metrics.MeasureSince([]string{"nomad", "file_system", "logs"}, time.Now())
|
||||
|
||||
// Decode the arguments
|
||||
var args cstructs.FsLogsRequest
|
||||
decoder := codec.NewDecoder(conn, structs.MsgpackHandle)
|
||||
encoder := codec.NewEncoder(conn, structs.MsgpackHandle)
|
||||
|
||||
if err := decoder.Decode(&args); err != nil {
|
||||
handleStreamResultError(err, pointer.Of(int64(500)), encoder)
|
||||
return
|
||||
}
|
||||
|
||||
authErr := f.srv.Authenticate(nil, &args)
|
||||
|
||||
// Check if we need to forward to a different region
|
||||
if r := args.RequestRegion(); r != f.srv.Region() {
|
||||
forwardRegionStreamingRpc(f.srv, conn, encoder, &args, "FileSystem.Logs",
|
||||
args.AllocID, &args.QueryOptions)
|
||||
return
|
||||
}
|
||||
f.srv.MeasureRPCRate("file_system", structs.RateMetricRead, &args)
|
||||
if authErr != nil {
|
||||
handleStreamResultError(structs.ErrPermissionDenied, nil, encoder)
|
||||
return
|
||||
}
|
||||
|
||||
// Verify the arguments.
|
||||
if args.AllocID == "" {
|
||||
handleStreamResultError(structs.ErrMissingAllocID, pointer.Of(int64(400)), encoder)
|
||||
return
|
||||
}
|
||||
|
||||
// Retrieve the allocation
|
||||
snap, err := f.srv.State().Snapshot()
|
||||
if err != nil {
|
||||
handleStreamResultError(err, nil, encoder)
|
||||
return
|
||||
}
|
||||
|
||||
alloc, err := getAlloc(snap, args.AllocID)
|
||||
if structs.IsErrUnknownAllocation(err) {
|
||||
handleStreamResultError(structs.NewErrUnknownAllocation(args.AllocID), pointer.Of(int64(404)), encoder)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
handleStreamResultError(err, nil, encoder)
|
||||
return
|
||||
}
|
||||
|
||||
// Check namespace read-logs *or* read-fs permissions.
|
||||
allowNsOp := acl.NamespaceValidator(
|
||||
acl.NamespaceCapabilityReadFS, acl.NamespaceCapabilityReadLogs)
|
||||
aclObj, err := f.srv.ResolveACL(&args)
|
||||
if err != nil {
|
||||
handleStreamResultError(err, nil, encoder)
|
||||
return
|
||||
} else if !allowNsOp(aclObj, alloc.Namespace) {
|
||||
handleStreamResultError(structs.ErrPermissionDenied, nil, encoder)
|
||||
return
|
||||
}
|
||||
|
||||
nodeID := alloc.NodeID
|
||||
|
||||
// Make sure Node is valid and new enough to support RPC
|
||||
node, err := snap.NodeByID(nil, nodeID)
|
||||
if err != nil {
|
||||
handleStreamResultError(err, pointer.Of(int64(500)), encoder)
|
||||
return
|
||||
}
|
||||
|
||||
if node == nil {
|
||||
err := fmt.Errorf("Unknown node %q", nodeID)
|
||||
handleStreamResultError(err, pointer.Of(int64(400)), encoder)
|
||||
return
|
||||
}
|
||||
|
||||
if err := nodeSupportsRpc(node); err != nil {
|
||||
handleStreamResultError(err, pointer.Of(int64(400)), encoder)
|
||||
return
|
||||
}
|
||||
|
||||
// Get the connection to the client either by forwarding to another server
|
||||
// or creating a direct stream
|
||||
var clientConn net.Conn
|
||||
state, ok := f.srv.getNodeConn(nodeID)
|
||||
if !ok {
|
||||
// Determine the Server that has a connection to the node.
|
||||
srv, err := f.srv.serverWithNodeConn(nodeID, f.srv.Region())
|
||||
if err != nil {
|
||||
var code *int64
|
||||
if structs.IsErrNoNodeConn(err) {
|
||||
code = pointer.Of(int64(404))
|
||||
}
|
||||
handleStreamResultError(err, code, encoder)
|
||||
return
|
||||
}
|
||||
|
||||
// Get a connection to the server
|
||||
conn, err := f.srv.streamingRpc(srv, "FileSystem.Logs")
|
||||
if err != nil {
|
||||
handleStreamResultError(err, nil, encoder)
|
||||
return
|
||||
}
|
||||
|
||||
clientConn = conn
|
||||
} else {
|
||||
stream, err := NodeStreamingRpc(state.Session, "FileSystem.Logs")
|
||||
if err != nil {
|
||||
handleStreamResultError(err, nil, encoder)
|
||||
return
|
||||
}
|
||||
clientConn = stream
|
||||
}
|
||||
defer clientConn.Close()
|
||||
|
||||
// Send the request.
|
||||
outEncoder := codec.NewEncoder(clientConn, structs.MsgpackHandle)
|
||||
if err := outEncoder.Encode(args); err != nil {
|
||||
handleStreamResultError(err, nil, encoder)
|
||||
return
|
||||
}
|
||||
|
||||
structs.Bridge(conn, clientConn)
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
-96
@@ -1,96 +0,0 @@
|
||||
// Copyright IBM Corp. 2015, 2025
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package nomad
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
log "github.com/hashicorp/go-hclog"
|
||||
metrics "github.com/hashicorp/go-metrics/compat"
|
||||
cstructs "github.com/hashicorp/nomad/client/structs"
|
||||
"github.com/hashicorp/nomad/nomad/structs"
|
||||
)
|
||||
|
||||
// ClientHostVolume is the client RPC endpoint for host volumes
|
||||
type ClientHostVolume struct {
|
||||
srv *Server
|
||||
ctx *RPCContext
|
||||
logger log.Logger
|
||||
}
|
||||
|
||||
func NewClientHostVolumeEndpoint(srv *Server, ctx *RPCContext) *ClientHostVolume {
|
||||
return &ClientHostVolume{srv: srv, ctx: ctx, logger: srv.logger.Named("client_host_volume")}
|
||||
}
|
||||
|
||||
func (c *ClientHostVolume) Create(args *cstructs.ClientHostVolumeCreateRequest, reply *cstructs.ClientHostVolumeCreateResponse) error {
|
||||
defer metrics.MeasureSince([]string{"nomad", "client_host_node", "create"}, time.Now())
|
||||
return c.sendVolumeRPC(
|
||||
args.NodeID,
|
||||
"HostVolume.Create",
|
||||
"ClientHostVolume.Create",
|
||||
structs.RateMetricWrite,
|
||||
args,
|
||||
reply,
|
||||
)
|
||||
}
|
||||
|
||||
func (c *ClientHostVolume) Register(args *cstructs.ClientHostVolumeRegisterRequest, reply *cstructs.ClientHostVolumeRegisterResponse) error {
|
||||
defer metrics.MeasureSince([]string{"nomad", "client_host_node", "register"}, time.Now())
|
||||
return c.sendVolumeRPC(
|
||||
args.NodeID,
|
||||
"HostVolume.Register",
|
||||
"ClientHostVolume.Register",
|
||||
structs.RateMetricWrite,
|
||||
args,
|
||||
reply,
|
||||
)
|
||||
}
|
||||
|
||||
func (c *ClientHostVolume) Delete(args *cstructs.ClientHostVolumeDeleteRequest, reply *cstructs.ClientHostVolumeDeleteResponse) error {
|
||||
defer metrics.MeasureSince([]string{"nomad", "client_host_volume", "delete"}, time.Now())
|
||||
return c.sendVolumeRPC(
|
||||
args.NodeID,
|
||||
"HostVolume.Delete",
|
||||
"ClientHostVolume.Delete",
|
||||
structs.RateMetricWrite,
|
||||
args,
|
||||
reply,
|
||||
)
|
||||
}
|
||||
|
||||
func (c *ClientHostVolume) sendVolumeRPC(nodeID, method, fwdMethod, op string, args any, reply any) error {
|
||||
// client requests aren't RequestWithIdentity, so we use a placeholder here
|
||||
// to populate the identity data for metrics
|
||||
identityReq := &structs.GenericRequest{}
|
||||
aclObj, err := c.srv.AuthenticateServerOnly(c.ctx, identityReq)
|
||||
c.srv.MeasureRPCRate("client_host_volume", op, identityReq)
|
||||
|
||||
if err != nil || !aclObj.AllowServerOp() {
|
||||
return structs.ErrPermissionDenied
|
||||
}
|
||||
|
||||
// Make sure Node is valid and new enough to support RPC
|
||||
snap, err := c.srv.State().Snapshot()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = getNodeForRpc(snap, nodeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Get the connection to the client
|
||||
state, ok := c.srv.getNodeConn(nodeID)
|
||||
if !ok {
|
||||
return findNodeConnAndForward(c.srv, nodeID, fwdMethod, args, reply)
|
||||
}
|
||||
|
||||
// Make the RPC
|
||||
if err := NodeRpc(state.Session, method, args, reply); err != nil {
|
||||
return fmt.Errorf("%s error: %w", method, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
// Copyright IBM Corp. 2015, 2025
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package nomad
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
metrics "github.com/hashicorp/go-metrics/compat"
|
||||
"github.com/hashicorp/nomad/nomad/structs"
|
||||
)
|
||||
|
||||
type NodeIdentity struct {
|
||||
srv *Server
|
||||
}
|
||||
|
||||
func newNodeIdentityEndpoint(srv *Server) *NodeIdentity {
|
||||
return &NodeIdentity{
|
||||
srv: srv,
|
||||
}
|
||||
}
|
||||
|
||||
func (n *NodeIdentity) Get(args *structs.NodeIdentityGetReq, reply *structs.NodeIdentityGetResp) error {
|
||||
|
||||
// Prevent infinite loop between the leader and the follower with the target
|
||||
// node connection.
|
||||
args.QueryOptions.AllowStale = true
|
||||
|
||||
authErr := n.srv.Authenticate(nil, args)
|
||||
if done, err := n.srv.forward(structs.NodeIdentityGetRPCMethod, args, args, reply); done {
|
||||
return err
|
||||
}
|
||||
n.srv.MeasureRPCRate("client_identity", structs.RateMetricRead, args)
|
||||
if authErr != nil {
|
||||
return structs.ErrPermissionDenied
|
||||
}
|
||||
defer metrics.MeasureSince([]string{"nomad", "client_identity", "get"}, time.Now())
|
||||
|
||||
// Check node read permissions
|
||||
if aclObj, err := n.srv.ResolveACL(args); err != nil {
|
||||
return err
|
||||
} else if !aclObj.AllowNodeRead() {
|
||||
return structs.ErrPermissionDenied
|
||||
}
|
||||
|
||||
return n.srv.forwardClientRPC(structs.NodeIdentityGetRPCMethod, args.NodeID, args, reply)
|
||||
}
|
||||
|
||||
func (n *NodeIdentity) Renew(args *structs.NodeIdentityRenewReq, reply *structs.NodeIdentityRenewResp) error {
|
||||
|
||||
// Prevent infinite loop between the leader and the follower with the target
|
||||
// node connection.
|
||||
args.QueryOptions.AllowStale = true
|
||||
|
||||
authErr := n.srv.Authenticate(nil, args)
|
||||
if done, err := n.srv.forward(structs.NodeIdentityRenewRPCMethod, args, args, reply); done {
|
||||
return err
|
||||
}
|
||||
n.srv.MeasureRPCRate("client_identity", structs.RateMetricWrite, args)
|
||||
if authErr != nil {
|
||||
return structs.ErrPermissionDenied
|
||||
}
|
||||
defer metrics.MeasureSince([]string{"nomad", "client_identity", "renew"}, time.Now())
|
||||
|
||||
// Check node write permissions
|
||||
if aclObj, err := n.srv.ResolveACL(args); err != nil {
|
||||
return err
|
||||
} else if !aclObj.AllowNodeWrite() {
|
||||
return structs.ErrPermissionDenied
|
||||
}
|
||||
|
||||
return n.srv.forwardClientRPC(structs.NodeIdentityRenewRPCMethod, args.NodeID, args, reply)
|
||||
}
|
||||
-155
@@ -1,155 +0,0 @@
|
||||
// Copyright IBM Corp. 2015, 2025
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package nomad
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/hashicorp/nomad/ci"
|
||||
"github.com/hashicorp/nomad/client"
|
||||
"github.com/hashicorp/nomad/client/config"
|
||||
"github.com/hashicorp/nomad/nomad/structs"
|
||||
"github.com/hashicorp/nomad/testutil"
|
||||
"github.com/shoenig/test/must"
|
||||
)
|
||||
|
||||
func TestNodeIdentity_Get_Forward(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
|
||||
servers := []*Server{}
|
||||
for range 3 {
|
||||
s, cleanup := TestServer(t, func(c *Config) {
|
||||
c.BootstrapExpect = 3
|
||||
c.NumSchedulers = 0
|
||||
})
|
||||
t.Cleanup(cleanup)
|
||||
servers = append(servers, s)
|
||||
}
|
||||
|
||||
TestJoin(t, servers...)
|
||||
leader := testutil.WaitForLeaders(t, servers[0].RPC, servers[1].RPC, servers[2].RPC)
|
||||
|
||||
followers := []string{}
|
||||
for _, s := range servers {
|
||||
if addr := s.config.RPCAddr.String(); addr != leader {
|
||||
followers = append(followers, addr)
|
||||
}
|
||||
}
|
||||
t.Logf("leader=%s followers=%q", leader, followers)
|
||||
|
||||
clients := make([]*client.Client, 4)
|
||||
|
||||
for i := range 4 {
|
||||
c, cleanup := client.TestClient(t, func(c *config.Config) {
|
||||
c.Servers = followers
|
||||
})
|
||||
t.Cleanup(func() { _ = cleanup() })
|
||||
clients[i] = c
|
||||
}
|
||||
for _, c := range clients {
|
||||
testutil.WaitForClient(t, servers[0].RPC, c.NodeID(), c.Region())
|
||||
}
|
||||
|
||||
agentRPCs := []func(string, any, any) error{}
|
||||
nodeIDs := make([]string, 0, len(clients))
|
||||
|
||||
// Build list of agents and node IDs
|
||||
for _, s := range servers {
|
||||
agentRPCs = append(agentRPCs, s.RPC)
|
||||
}
|
||||
|
||||
for _, c := range clients {
|
||||
agentRPCs = append(agentRPCs, c.RPC)
|
||||
nodeIDs = append(nodeIDs, c.NodeID())
|
||||
}
|
||||
|
||||
// Iterate through all the agent RPCs to ensure that the renew RPC will
|
||||
// succeed, no matter which agent we connect to.
|
||||
for _, agentRPC := range agentRPCs {
|
||||
for _, nodeID := range nodeIDs {
|
||||
args := &structs.NodeIdentityGetReq{
|
||||
NodeID: nodeID,
|
||||
QueryOptions: structs.QueryOptions{
|
||||
Region: clients[0].Region(),
|
||||
},
|
||||
}
|
||||
must.NoError(t,
|
||||
agentRPC(structs.NodeIdentityGetRPCMethod,
|
||||
args,
|
||||
&structs.NodeIdentityGetResp{},
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeIdentity_Renew_Forward(t *testing.T) {
|
||||
ci.Parallel(t)
|
||||
|
||||
servers := []*Server{}
|
||||
for i := 0; i < 3; i++ {
|
||||
s, cleanup := TestServer(t, func(c *Config) {
|
||||
c.BootstrapExpect = 3
|
||||
c.NumSchedulers = 0
|
||||
})
|
||||
t.Cleanup(cleanup)
|
||||
servers = append(servers, s)
|
||||
}
|
||||
|
||||
TestJoin(t, servers...)
|
||||
leader := testutil.WaitForLeaders(t, servers[0].RPC, servers[1].RPC, servers[2].RPC)
|
||||
|
||||
followers := []string{}
|
||||
for _, s := range servers {
|
||||
if addr := s.config.RPCAddr.String(); addr != leader {
|
||||
followers = append(followers, addr)
|
||||
}
|
||||
}
|
||||
t.Logf("leader=%s followers=%q", leader, followers)
|
||||
|
||||
clients := make([]*client.Client, 4)
|
||||
|
||||
for i := 0; i < 4; i++ {
|
||||
c, cleanup := client.TestClient(t, func(c *config.Config) {
|
||||
c.Servers = followers
|
||||
})
|
||||
t.Cleanup(func() { _ = cleanup() })
|
||||
clients[i] = c
|
||||
}
|
||||
for _, c := range clients {
|
||||
testutil.WaitForClient(t, servers[0].RPC, c.NodeID(), c.Region())
|
||||
}
|
||||
|
||||
agentRPCs := []func(string, any, any) error{}
|
||||
nodeIDs := make([]string, 0, len(clients))
|
||||
|
||||
// Build list of agents and node IDs
|
||||
for _, s := range servers {
|
||||
agentRPCs = append(agentRPCs, s.RPC)
|
||||
}
|
||||
|
||||
for _, c := range clients {
|
||||
agentRPCs = append(agentRPCs, c.RPC)
|
||||
nodeIDs = append(nodeIDs, c.NodeID())
|
||||
}
|
||||
|
||||
// Iterate through all the agent RPCs to ensure that the renew RPC will
|
||||
// succeed, no matter which agent we connect to.
|
||||
for _, agentRPC := range agentRPCs {
|
||||
for _, nodeID := range nodeIDs {
|
||||
args := &structs.NodeIdentityRenewReq{
|
||||
NodeID: nodeID,
|
||||
QueryOptions: structs.QueryOptions{
|
||||
Region: clients[0].Region(),
|
||||
},
|
||||
}
|
||||
must.NoError(t,
|
||||
agentRPC(structs.NodeIdentityRenewRPCMethod,
|
||||
args,
|
||||
&structs.NodeIdentityRenewResp{},
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
// Copyright IBM Corp. 2015, 2025
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package nomad
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
log "github.com/hashicorp/go-hclog"
|
||||
metrics "github.com/hashicorp/go-metrics/compat"
|
||||
"github.com/hashicorp/nomad/nomad/structs"
|
||||
nstructs "github.com/hashicorp/nomad/nomad/structs"
|
||||
)
|
||||
|
||||
type NodeMeta struct {
|
||||
srv *Server
|
||||
logger log.Logger
|
||||
}
|
||||
|
||||
func newNodeMetaEndpoint(srv *Server) *NodeMeta {
|
||||
n := &NodeMeta{
|
||||
srv: srv,
|
||||
logger: srv.logger.Named("node_meta"),
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func (n *NodeMeta) Apply(args *structs.NodeMetaApplyRequest, reply *structs.NodeMetaResponse) error {
|
||||
const method = "NodeMeta.Apply"
|
||||
|
||||
// Prevent infinite loop between leader and
|
||||
// follower-with-the-target-node-connection.
|
||||
args.QueryOptions.AllowStale = true
|
||||
|
||||
authErr := n.srv.Authenticate(nil, args)
|
||||
if done, err := n.srv.forward(method, args, args, reply); done {
|
||||
return err
|
||||
}
|
||||
n.srv.MeasureRPCRate("node_meta", nstructs.RateMetricRead, args)
|
||||
if authErr != nil {
|
||||
return nstructs.ErrPermissionDenied
|
||||
}
|
||||
defer metrics.MeasureSince([]string{"nomad", "client_meta", "apply"}, time.Now())
|
||||
|
||||
// Check node write permissions
|
||||
if aclObj, err := n.srv.ResolveACL(args); err != nil {
|
||||
return err
|
||||
} else if !aclObj.AllowNodeWrite() {
|
||||
return nstructs.ErrPermissionDenied
|
||||
}
|
||||
|
||||
return n.srv.forwardClientRPC(method, args.NodeID, args, reply)
|
||||
}
|
||||
|
||||
func (n *NodeMeta) Read(args *structs.NodeSpecificRequest, reply *structs.NodeMetaResponse) error {
|
||||
const method = "NodeMeta.Read"
|
||||
|
||||
// Prevent infinite loop between leader and
|
||||
// follower-with-the-target-node-connection.
|
||||
args.QueryOptions.AllowStale = true
|
||||
|
||||
authErr := n.srv.Authenticate(nil, args)
|
||||
if done, err := n.srv.forward(method, args, args, reply); done {
|
||||
return err
|
||||
}
|
||||
n.srv.MeasureRPCRate("node_meta", nstructs.RateMetricRead, args)
|
||||
if authErr != nil {
|
||||
return nstructs.ErrPermissionDenied
|
||||
}
|
||||
defer metrics.MeasureSince([]string{"nomad", "client_meta", "read"}, time.Now())
|
||||
|
||||
// Check node read permissions
|
||||
if aclObj, err := n.srv.ResolveACL(args); err != nil {
|
||||
return err
|
||||
} else if !aclObj.AllowNodeRead() {
|
||||
return nstructs.ErrPermissionDenied
|
||||
}
|
||||
|
||||
return n.srv.forwardClientRPC(method, args.NodeID, args, reply)
|
||||
}
|
||||
Loaded 100 of 242 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user