Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions .github/workflows/js-qa.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
name: JavaScript QA

on:
push:
branches:
- main
paths:
- "**/*.js"
- "**/*.mjs"
- .github/workflows/js-qa.yaml
pull_request:
paths:
- "**/*.js"
- "**/*.mjs"
- .github/workflows/js-qa.yaml

jobs:
check-js-syntax:
name: node --check
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v7
Comment thread
hk21702 marked this conversation as resolved.

- name: Syntax check all JS files
run: |
while IFS= read -r file; do
echo "Checking $file"
node --check "$file"
Comment thread
CarlosNihelton marked this conversation as resolved.
done < <(find . -type f \( -name '*.js' -o -name '*.mjs' \) -not -path './.git/*' -print0 | sort -z | tr '\0' '\n')

unit-tests:
name: Node.js unit tests
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v7

- name: Run unit tests
run: node --test 'gh-actions/**/*.test.{js,mjs}'
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
.vscode
.idea
.DS_Store

# Node dependencies for JS actions
node_modules/
47 changes: 47 additions & 0 deletions gh-actions/infra/jaas-auth/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
name: JAAS auth
description: >-
Authenticate to a JAAS controller as a service account using Juju client
credentials, then automatically clean up (juju logout and remove client
state) afterwards, even when the job fails. Cleanup can be disabled with the
`cleanup` input.

inputs:
jaas-controller:
description: Name used to register the JAAS controller in the local Juju client
required: true
jaas-controller-host:
description: JAAS controller host name or alias
required: true
juju-client-id:
description: Juju client ID for the service account
required: true
juju-client-secret:
description: Juju client secret for the service account
required: true
clear-ca-cert:
description: >-
Clear the CA certificate embedded in controllers.yaml by `juju login
--trust`, so the client falls back to the system CA store. This is a
workaround for https://github.com/juju/juju/pull/22931; once that fix is
released to the stable Juju snap this default can be flipped to false.
required: false
default: "true"
cleanup:
description: >-
Run the post-job cleanup step (juju logout and removal of the Juju
client state). Set to "false" to skip cleanup, e.g. when the runner
itself is ephemeral or you want to reuse the client state.
required: false
default: "true"

outputs:
juju-data:
description: >-
Directory containing the Juju client state (JUJU_DATA), set by the
main entrypoint via GITHUB_OUTPUT.

runs:
using: node24
main: dist/main/index.mjs
post: dist/post/index.mjs
post-if: always()
56 changes: 56 additions & 0 deletions gh-actions/infra/jaas-auth/dist/clear_ca_cert.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/**
* Clear the embedded controller CA certificate from a Juju controllers file.
*
* After `juju login --trust`, the controllers.yaml file embeds the
* controller's CA certificate. Clearing it makes the Juju client fall back to
* the runner's system CA store, which is expected to trust the JAAS endpoint
* certificate.
*
* This is a workaround for https://github.com/juju/juju/pull/22931. Once that
* fix is released to the stable Juju snap, this step can be disabled (see the
* `clear-ca-cert` input of the jaas-auth action).
*/
import fs from "node:fs";

const CA_CERT_PREFIX = " ca-cert:";
const CA_CERT_INDENT = 8;

/**
* Replace the controller's ca-cert value (which may be a multiline block
* scalar) with an empty string, preserving the file's permissions via an
* atomic replace.
*
* @param {string} controllersFile Path to the controllers.yaml file.
*/
export function clearCaCert(controllersFile) {
const lines = fs
.readFileSync(controllersFile, "utf8")
.split(/(?<=\n)/);

const caCertLines = [];
lines.forEach((line, index) => {
if (line.startsWith(CA_CERT_PREFIX)) caCertLines.push(index);
});
if (caCertLines.length !== 1) {
throw new Error(
`expected one controller ca-cert entry, found ${caCertLines.length}`,
);
}

const start = caCertLines[0];
let end = start + 1;
while (end < lines.length) {
const line = lines[end];
const indentation = line.length - line.trimStart().length;
if (line.trim() && indentation <= CA_CERT_INDENT) break;
end += 1;
}

lines.splice(start, end - start, `${CA_CERT_PREFIX} ""\n`);

const mode = fs.statSync(controllersFile).mode & 0o7777;
const temporaryFile = controllersFile + ".tmp";
fs.writeFileSync(temporaryFile, lines.join(""), { encoding: "utf8" });
fs.chmodSync(temporaryFile, mode);
fs.renameSync(temporaryFile, controllersFile);
}
91 changes: 91 additions & 0 deletions gh-actions/infra/jaas-auth/dist/main/index.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
/**
* JAAS auth — main entrypoint.
*
* Authenticates to the JAAS controller as a service account using the given
* Juju client credentials, and outputs the JUJU_DATA directory holding the
* authenticated client state. Cleanup is handled by the companion post
* entrypoint (dist/post/index.mjs), which GitHub always runs, even when this
* step or the job fails.
*/
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";

import { clearCaCert } from "../clear_ca_cert.mjs";

function run(command, args, options = {}) {
execFileSync(command, args, { stdio: "inherit", ...options });
}

function inputEnv(name) {
return `INPUT_${name.replace(/ /g, "_").toUpperCase()}`;
}

function required(name) {
const value = process.env[inputEnv(name)];
if (!value) throw new Error(`missing required input: ${name}`);
return value;
}

function input(name, fallback = "") {
return process.env[inputEnv(name)] || fallback;
}

function appendEnv(name, value) {
fs.appendFileSync(process.env.GITHUB_ENV, `${name}=${value}\n`);
}

try {
const controller = required("jaas-controller");
const controllerHost = required("jaas-controller-host");
const clientId = required("juju-client-id");
const clientSecret = required("juju-client-secret");

run("sudo", ["snap", "install", "juju"]);
run("juju", ["version"]);

// Prepare the Juju client state directory.
const jujuData = path.join(process.env.RUNNER_TEMP || "/tmp", "juju-data");
fs.mkdirSync(jujuData, { recursive: true, mode: 0o700 });
fs.chmodSync(jujuData, 0o700);

// Mask and export the credentials for later steps in this job.
console.log(`::add-mask::${clientId}`);
console.log(`::add-mask::${clientSecret}`);
appendEnv("JUJU_CLIENT_ID", clientId);
appendEnv("JUJU_CLIENT_SECRET", clientSecret);

const env = {
...process.env,
JUJU_DATA: jujuData,
JUJU_CLIENT_ID: clientId,
JUJU_CLIENT_SECRET: clientSecret,
};

// Authenticate to JAAS.
run(
"juju",
[
"login",
"--no-browser-login",
"-c",
controller,
controllerHost,
"--trust",
"--no-prompt",
],
{ env, stdio: ["inherit", "ignore", "inherit"] },
);

if (input("clear-ca-cert", "true") === "true") {
clearCaCert(path.join(jujuData, "controllers.yaml"));
}

fs.appendFileSync(process.env.GITHUB_OUTPUT, `juju-data=${jujuData}\n`);
// Persist for the post (cleanup) entrypoint.
appendEnv("JAAS_AUTH_JUJU_DATA", jujuData);
appendEnv("JAAS_AUTH_CLEANUP", input("cleanup", "true"));
Comment thread
CarlosNihelton marked this conversation as resolved.
} catch (error) {
console.error(`::error::${error.message}`);
process.exitCode = 1;
}
47 changes: 47 additions & 0 deletions gh-actions/infra/jaas-auth/dist/post/index.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/**
* JAAS auth — post (cleanup) entrypoint.
*
* Runs unconditionally after the job (post-if: always()). Logs out of the Juju
* controller and removes the client state, then clears the related environment
* variables. All steps are best-effort so a failure does not mask the job's
* original result. Skipped entirely when the `cleanup` input was "false".
*/
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";

const jujuData =
process.env.JAAS_AUTH_JUJU_DATA ||
path.join(process.env.RUNNER_TEMP || "/tmp", "juju-data");

function appendEnv(name, value) {
fs.appendFileSync(process.env.GITHUB_ENV, `${name}=${value}\n`);
}

function main() {
if ((process.env.JAAS_AUTH_CLEANUP || "true") !== "true") {
console.log("Skipping JAAS cleanup (cleanup input was not 'true').");
return;
}

// Best-effort: log out of the controller before deleting its state. There is
// only one controller in this JUJU_DATA, so a bare `juju logout` suffices.
try {
execFileSync("juju", ["logout"], {
env: { ...process.env, JUJU_DATA: jujuData },
stdio: "inherit",
});
} catch (error) {
console.error(
`::warning::juju logout failed; removing client state anyway: ${error.message}`,
);
}

fs.rmSync(jujuData, { recursive: true, force: true });

appendEnv("JUJU_CLIENT_ID", "");
appendEnv("JUJU_CLIENT_SECRET", "");
appendEnv("JUJU_DATA", "");
Comment thread
CarlosNihelton marked this conversation as resolved.
}

main();
77 changes: 77 additions & 0 deletions gh-actions/infra/jaas-auth/tests/clear_ca_cert.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";

import { clearCaCert } from "../dist/clear_ca_cert.mjs";

function writeControllers(t, content) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "clear-ca-cert-"));
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
const controllersFile = path.join(dir, "controllers.yaml");
fs.writeFileSync(controllersFile, content, { encoding: "utf8" });
fs.chmodSync(controllersFile, 0o600);
return controllersFile;
}

test("clears multiline ca-cert", (t) => {
const controllersFile = writeControllers(
t,
`controllers:
jaas:
uuid: test
api-endpoints: [jaas.example.com:443/api]
ca-cert: |-
-----BEGIN CERTIFICATE-----
certificate-data
-----END CERTIFICATE-----
cloud: ""
current-controller: jaas
`,
);

clearCaCert(controllersFile);

assert.equal(
fs.readFileSync(controllersFile, "utf8"),
`controllers:
jaas:
uuid: test
api-endpoints: [jaas.example.com:443/api]
ca-cert: ""
cloud: ""
current-controller: jaas
`,
);
assert.equal(fs.statSync(controllersFile).mode & 0o777, 0o600);
});

test("empty ca-cert is idempotent", (t) => {
const content = `controllers:
jaas:
uuid: test
ca-cert: ""
cloud: ""
current-controller: jaas
`;
const controllersFile = writeControllers(t, content);

clearCaCert(controllersFile);

assert.equal(fs.readFileSync(controllersFile, "utf8"), content);
});

test("missing ca-cert raises", (t) => {
const controllersFile = writeControllers(
t,
`controllers:
jaas:
uuid: test
current-controller: jaas
`,
);

assert.throws(() => clearCaCert(controllersFile), Error);
});
Loading