· 4 min read

Upload Jenkins Secret File Credential via API


Originally published on LinkedIn (January 2018).

Background

Tip: skip to the Solution section if you just want the answer — but I like reading the thought process when someone posts one, and the context of the problem, so that’s how this one’s written.

We were building a Kubernetes cluster for a number of teams at KPN, and ran into a task that looked simple but turned out not to be: whenever we created a namespace in Kubernetes for a team, we wanted to upload a kubeconfig with credentials scoped to that namespace, into the corresponding team folder in Jenkins, so they could deploy their containers via a Jenkins pipeline.

The workflow: create a new namespace, which creates a token, which gets linked to a role, which goes into a kubeconfig file, which then gets uploaded to the Jenkins credentials store. That last step is what this post is about. If you want to upload a file to the credentials store (rather than SSH keys), the credential type you want is called secret file.

Search for a solution

A bit of searching turns up a few hints:

  1. Jenkins’ own credentials plugin documentation has several API examples using XML templates — none of them for secret file uploads, and I don’t particularly enjoy working with XML.
  2. A short third-party article with a few nice JSON examples — still nothing on uploading a secret file specifically.
  3. A few people suggested Groovy scripts or jenkins-cli instead. Both work, but I wanted the API.

So I went looking for the actual data structure Jenkins needs. I found this snippet under /var/lib/jenkins/credentials.xml:

<org.jenkinsci.plugins.plaincredentials.impl.FileCredentialsImpl plugin="plain-credentials@1.4">
   <scope>GLOBAL</scope>
   <id>gitlab-vault-pass</id>
   <description>Credentials for Gitlab vault</description>
   <fileName>credentials.txt</fileName>
   <secretBytes>{VP/hadsda%462AdfP1lbNHLqJ9900x/T25Zmq45s=}</secretBytes>
</org.jenkinsci.plugins.plaincredentials.impl.FileCredentialsImpl>

The problem: the file content here is already uploaded and encrypted. Even with a fully API-compatible XML structure, you’d still need to encrypt the file content yourself before it goes in <secretBytes>.

The next move was going back to the UI upload flow and using the browser’s dev tools to capture the actual request, to figure out what to send myself. Two useful things came out of that:

  • In the network tab, every request shows its full URL — I didn’t know the API’s exact shape (still don’t, entirely), but I wanted to upload to a specific folder and context, and the network tab gave me the exact URL and method: manual API discovery.
  • Inspecting the request body turned up this JSON, which also answered a question I didn’t know how to search for — the exact name of the Jenkins credentials class:
{
  "": "4",
  "credentials": {
    "file": "file0",
    "id": "another",
    "description": "another",
    "stapler-class": "org.jenkinsci.plugins.plaincredentials.impl.FileCredentialsImpl",
    "$class": "org.jenkinsci.plugins.plaincredentials.impl.FileCredentialsImpl"
  }
}

Following the second article’s similar JSON snippets, I tried building the credential via curl and failed repeatedly — wrong content-type, “the page expects a form submission,” Java null pointer exceptions that traced back to file.getName(), meaning my file wasn’t even being picked up. I tried @file0, absolute paths, every combination of @ and file paths I could think of, and every combination of --data, --form, --url-encode, and --data-binary. No luck.

At that point I asked for help — my colleague found that Chrome’s dev tools can export a request as a raw curl command. A good start, but not enough on its own — what Chrome produced was a one-page-long curl command. After stripping unnecessary headers it was still thirty lines. Removing the multipart data by hand, piece by piece, eventually got to something smaller and actually working, if still fairly ugly:

curl -X POST \
'https://jenkins.local' \
-H 'Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryjXHNP28BBp7yHoU5' \
--data-binary $'------WebKitFormBoundaryjXHNP28BBp7yHoU5\r\nContent-Disposition: form-data; name="file0"; filename="vpn.sh"\r\nContent-Type: application/octet-stream\r\n\r\n\r\n------WebKitFormBoundaryjXHNP28BBp7yHoU5\r\nContent-Disposition: form-data; name="json"\r\n\r\n{"": "4", "credentials": {"file": "file0", "id": "test", "description": "HELLO", "stapler-class": "org.jenkinsci.plugins.plaincredentials.impl.FileCredentialsImpl", "$class": "org.jenkinsci.plugins.plaincredentials.impl.FileCredentialsImpl"}}\r\n------WebKitFormBoundaryjXHNP28BBp7yHoU5\r\nContent-Disposition: form-data; name="Submit"\r\n\r\nOK\r\n------WebKitFormBoundaryjXHNP28BBp7yHoU5--\r\n'

The important part is this pairing:

Content-Disposition: form-data; name="file0"; filename="vpn.sh"
Content-Type: application/octet-stream

filename recognizes an actual file path on your machine, even a relative one, while name is what the JSON body uses to refer to that file:

Content-Disposition: form-data; name="json"

{"": "4", "credentials": {"file": "file0", "id": "test", ...

Given how much time this had already cost, I just wanted a small Ansible role to do this and fold it into our Kubernetes install automation. But with all those backslashes and mixed quoting, getting the exact same string through Ansible’s command or shell module turned into its own small ordeal — especially since I wanted the string dynamic, so we could provide multiple credentials with different data. Any attempt to template it or touch the newline characters broke the request again.

At that point I gave up on hand-editing the raw multipart body and looked for a way to rebuild the request in something readable. I’d used Advanced REST Client before, but our servers sit behind a stepping-stone server and a proxy, and ARC doesn’t support proxy configuration. Postman does, and it also lets you export a request in whatever format you like — so after configuring the proxy there, I exported the working request as curl.

Solution

Here it is, clean:

curl -X POST \
  https://jenkins.local/job/TEAM-FOLDER/credentials/store/folder/domain/_/createCredentials \
  -H 'content-type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW' \
  -F file0=@/Users/maksym/secret \
  -F 'json={"": "4", "credentials": {"file": "file0", "id": "test", "description": "HELLO-curl", "stapler-class": "org.jenkinsci.plugins.plaincredentials.impl.FileCredentialsImpl", "$class": "org.jenkinsci.plugins.plaincredentials.impl.FileCredentialsImpl"}}'

Small bonus, for a dynamic boundary string:

echo "-----MultipartDelimeter$$$RANDOM$RANDOM$RANDOM"

In Ansible, that echo line only works properly through the shell module, not command — I never dug into why, and didn’t need to.

Update: the content-type header turned out not to be necessary at all, which makes the whole thing even smaller:

curl -X POST \
  https://jenkins.local/job/TEAM-FOLDER/credentials/store/folder/domain/_/createCredentials \
  -F secret=@/Users/maksym/secret \
  -F 'json={"": "4", "credentials": {"file": "secret", "id": "test", "description": "HELLO-curl", "stapler-class": "org.jenkinsci.plugins.plaincredentials.impl.FileCredentialsImpl", "$class": "org.jenkinsci.plugins.plaincredentials.impl.FileCredentialsImpl"}}'

Good luck with Jenkins automation!