diff --git a/.github/actions/README.md b/.github/actions/README.md new file mode 100644 index 00000000..b5613e10 --- /dev/null +++ b/.github/actions/README.md @@ -0,0 +1,40 @@ +# Reusable Container API Actions + +Composite GitHub Actions that wrap the [Create-a-Container API](../../create-a-container/openapi.v1.yaml). They are the single source of truth for talking to the API from CI, so downstream consumers (notably [`mieweb/launchpad`](../../launchpad.yml)) never embed raw `curl` calls that drift out of sync with API changes. + +Each action is a thin, single-purpose wrapper around one API operation. Orchestration (naming containers, deciding when to recreate, gating on events) belongs to the caller. + +| Action | Purpose | API call | +| --- | --- | --- | +| [`get-container`](get-container/action.yml) | Fetch a container by hostname | `GET /api/v1/sites/{siteId}/containers?hostname=` | +| [`create-container`](create-container/action.yml) | Create a container | `POST /api/v1/sites/{siteId}/containers` | +| [`delete-container`](delete-container/action.yml) | Delete a container by ID | `DELETE /api/v1/sites/{siteId}/containers/{id}` | +| [`wait-for-job`](wait-for-job/action.yml) | Poll a job until it finishes | `GET /api/v1/jobs/{id}` | + +## Usage + +Reference an action by its directory (GitHub resolves the `action.yml` inside it): + +```yaml +- uses: mieweb/opensource-server/.github/actions/create-container@latest + id: create + with: + api_url: ${{ inputs.api_url }} + api_key: ${{ inputs.api_key }} + site_id: "1" + hostname: my-container + template_name: ghcr.io/mieweb/timeharbor:latest + +- uses: mieweb/opensource-server/.github/actions/wait-for-job@latest + if: steps.create.outputs.created == 'true' + with: + api_url: ${{ inputs.api_url }} + api_key: ${{ inputs.api_key }} + job_id: ${{ steps.create.outputs.job_id }} +``` + +`wait-for-job` is kept separate from `create-container` so future flows (e.g. modifying an existing container) can reuse the polling logic. + +## Versioning and pinning + +Use a floating ref (`@latest` or `@main`) only when you accept breaking changes on each release. diff --git a/.github/actions/create-container/action.yml b/.github/actions/create-container/action.yml new file mode 100644 index 00000000..c68ee0a1 --- /dev/null +++ b/.github/actions/create-container/action.yml @@ -0,0 +1,126 @@ +name: Create Container +description: Create a container via the Create-a-Container API and report its creation job. +author: mieweb + +inputs: + api_url: + description: Base URL for the Container API (e.g. https://containers.example.com). + required: true + api_key: + description: API key used as a Bearer token for authentication. + required: true + site_id: + description: Site ID the container will belong to. + required: false + default: "1" + hostname: + description: Hostname for the new container. + required: true + template_name: + description: Container template/image (e.g. ghcr.io/mieweb/timeharbor:latest). + required: false + default: "" + services: + description: Services configuration (JSON array string). + required: false + default: "" + container_env_vars: + description: Environment variables for the container (JSON string). + required: false + default: "" + +outputs: + container_id: + description: The new (or existing, on conflict) container ID. + value: ${{ steps.create.outputs.container_id }} + job_id: + description: The asynchronous creation job ID (empty when created synchronously). + value: ${{ steps.create.outputs.job_id }} + created: + description: "'true' when an asynchronous creation job was enqueued (poll with wait-for-job)." + value: ${{ steps.create.outputs.created }} + ready: + description: "'true' when the container is already usable (created synchronously or already existed)." + value: ${{ steps.create.outputs.ready }} + +runs: + using: composite + steps: + - name: Create container + id: create + shell: bash + env: + API_URL: ${{ inputs.api_url }} + API_KEY: ${{ inputs.api_key }} + SITE_ID: ${{ inputs.site_id }} + CONTAINER_HOSTNAME: ${{ inputs.hostname }} + TEMPLATE_NAME: ${{ inputs.template_name }} + SERVICES: ${{ inputs.services }} + CONTAINER_ENV_VARS: ${{ inputs.container_env_vars }} + run: | + set -uo pipefail + echo "Creating container '$CONTAINER_HOSTNAME' (site $SITE_ID)..." + + CREATE_PAYLOAD=$(jq -n \ + --arg hostname "$CONTAINER_HOSTNAME" \ + --arg template "$TEMPLATE_NAME" \ + --arg services "$SERVICES" \ + --arg env_vars "$CONTAINER_ENV_VARS" \ + '{ + hostname: $hostname, + template: $template, + services: ($services | fromjson? // []), + environment: ($env_vars | fromjson? // {}) + }') + + echo "--- Create Payload ---" + echo "$CREATE_PAYLOAD" + echo "----------------------" + + RESPONSE=$(curl -s -w "\n%{http_code}" \ + -X POST "$API_URL/api/v1/sites/$SITE_ID/containers" \ + -H "Authorization: Bearer $API_KEY" \ + -H "Accept: application/json" \ + -H "Content-Type: application/json" \ + -d "$CREATE_PAYLOAD") + + BODY=$(echo "$RESPONSE" | sed '$d') + CODE=$(echo "$RESPONSE" | tail -1) + + if [ "$CODE" -ge 200 ] && [ "$CODE" -lt 300 ]; then + echo "--- Create Response ---" + echo "$BODY" + echo "----------------------" + NEW_ID=$(echo "$BODY" | jq -r '.data.containerId // .data.id // .container.id // .id // empty') + JOB_ID=$(echo "$BODY" | jq -r '.data.jobId // .data.job_id // .data.creationJobId // .jobId // .job_id // empty') + if [ -z "$NEW_ID" ] || [ "$NEW_ID" == "null" ]; then + echo "::error::Creation succeeded (HTTP $CODE) but the response contained no container ID. Response: $BODY" + exit 1 + fi + echo "container_id=$NEW_ID" >> "$GITHUB_OUTPUT" + if [ -n "$JOB_ID" ] && [ "$JOB_ID" != "null" ]; then + echo "Container created (ID: $NEW_ID, Job ID: $JOB_ID)." + { + echo "job_id=$JOB_ID" + echo "created=true" + echo "ready=false" + } >> "$GITHUB_OUTPUT" + else + echo "Container created synchronously (ID: $NEW_ID)." + { + echo "created=false" + echo "ready=true" + } >> "$GITHUB_OUTPUT" + fi + elif [ "$CODE" -eq 409 ]; then + EXISTING_ID=$(echo "$BODY" | jq -r '.data.containerId // .data.id // .container.id // empty') + echo "Container already exists (conflict). Using existing container ID: $EXISTING_ID" + { + echo "container_id=$EXISTING_ID" + echo "created=false" + echo "ready=true" + } >> "$GITHUB_OUTPUT" + else + echo "::error::Failed to create container. HTTP $CODE — $BODY" + exit 1 + fi diff --git a/.github/actions/delete-container/action.yml b/.github/actions/delete-container/action.yml new file mode 100644 index 00000000..1c1b694e --- /dev/null +++ b/.github/actions/delete-container/action.yml @@ -0,0 +1,52 @@ +name: Delete Container +description: Delete a container by ID via the Create-a-Container API. +author: mieweb + +inputs: + api_url: + description: Base URL for the Container API (e.g. https://containers.example.com). + required: true + api_key: + description: API key used as a Bearer token for authentication. + required: true + site_id: + description: Site ID that owns the container. + required: false + default: "1" + container_id: + description: Numeric ID of the container to delete. + required: true + +outputs: + deleted: + description: "'true' if the container was deleted, otherwise 'false'." + value: ${{ steps.delete.outputs.deleted }} + +runs: + using: composite + steps: + - name: Delete container + id: delete + shell: bash + env: + API_URL: ${{ inputs.api_url }} + API_KEY: ${{ inputs.api_key }} + SITE_ID: ${{ inputs.site_id }} + CONTAINER_ID: ${{ inputs.container_id }} + run: | + set -uo pipefail + echo "Deleting container ID $CONTAINER_ID (site $SITE_ID)..." + + CODE=$(curl -s -o /dev/null -w "%{http_code}" \ + -X DELETE "$API_URL/api/v1/sites/$SITE_ID/containers/$CONTAINER_ID" \ + -H "Authorization: Bearer $API_KEY" \ + -H "Accept: application/json") + + if [ "$CODE" -ge 200 ] && [ "$CODE" -lt 300 ]; then + echo "Container $CONTAINER_ID deleted (HTTP $CODE)." + echo "deleted=true" >> "$GITHUB_OUTPUT" + else + echo "deleted=false" >> "$GITHUB_OUTPUT" + echo "::error::Failed to delete container $CONTAINER_ID. HTTP $CODE" + exit 1 + fi diff --git a/.github/actions/get-container/action.yml b/.github/actions/get-container/action.yml new file mode 100644 index 00000000..6945ca1f --- /dev/null +++ b/.github/actions/get-container/action.yml @@ -0,0 +1,95 @@ +name: Get Container +description: Fetch a container's details by hostname from the Create-a-Container API. +author: mieweb + +inputs: + api_url: + description: Base URL for the Container API (e.g. https://containers.example.com). + required: true + api_key: + description: API key used as a Bearer token for authentication. + required: true + site_id: + description: Site ID that owns the container. + required: false + default: "1" + hostname: + description: Container hostname to look up. + required: true + +outputs: + found: + description: "'true' if a container was found, otherwise 'false'." + value: ${{ steps.query.outputs.found }} + container_id: + description: The container's numeric ID (empty if not found). + value: ${{ steps.query.outputs.container_id }} + status: + description: Container status (e.g. running); empty if not found. + value: ${{ steps.query.outputs.status }} + ipv4_address: + description: Container IPv4 address. + value: ${{ steps.query.outputs.ipv4_address }} + ssh_port: + description: Mapped SSH port. + value: ${{ steps.query.outputs.ssh_port }} + http_port: + description: Mapped HTTP port. + value: ${{ steps.query.outputs.http_port }} + node_name: + description: Proxmox node hosting the container. + value: ${{ steps.query.outputs.node_name }} + +runs: + using: composite + steps: + - name: Query container by hostname + id: query + shell: bash + env: + API_URL: ${{ inputs.api_url }} + API_KEY: ${{ inputs.api_key }} + SITE_ID: ${{ inputs.site_id }} + CONTAINER_HOSTNAME: ${{ inputs.hostname }} + run: | + set -uo pipefail + echo "Querying container '$CONTAINER_HOSTNAME' (site $SITE_ID)..." + + RAW=$(curl -s -w "\n%{http_code}" \ + -H "Authorization: Bearer $API_KEY" \ + -H "Accept: application/json" \ + "$API_URL/api/v1/sites/$SITE_ID/containers?hostname=$CONTAINER_HOSTNAME") + + HTTP_CODE=$(echo "$RAW" | tail -1) + BODY=$(echo "$RAW" | sed '$d') + + if [ "$HTTP_CODE" -lt 200 ] || [ "$HTTP_CODE" -ge 300 ]; then + echo "::error::Failed to query container. HTTP $HTTP_CODE — $BODY" + exit 1 + fi + + CONTAINER_ID=$(echo "$BODY" | jq -r '.data[0].id // .data[0].containerId // .containers[0].id // empty') + + if [ -z "$CONTAINER_ID" ] || [ "$CONTAINER_ID" == "null" ]; then + echo "No container found for '$CONTAINER_HOSTNAME'." + echo "found=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + STATUS=$(echo "$BODY" | jq -r '.data[0].status // .containers[0].status // "unknown"') + IPV4=$(echo "$BODY" | jq -r '.data[0].ipv4Address // .containers[0].ipv4Address // empty') + SSH_PORT=$(echo "$BODY" | jq -r '.data[0].sshPort // .containers[0].sshPort // empty') + HTTP_PORT=$(echo "$BODY" | jq -r '.data[0].httpPort // .containers[0].httpPort // empty') + NODE_NAME=$(echo "$BODY" | jq -r '.data[0].nodeName // .containers[0].nodeName // empty') + + { + echo "found=true" + echo "container_id=$CONTAINER_ID" + echo "status=$STATUS" + echo "ipv4_address=$IPV4" + echo "ssh_port=$SSH_PORT" + echo "http_port=$HTTP_PORT" + echo "node_name=$NODE_NAME" + } >> "$GITHUB_OUTPUT" + + echo "Found container '$CONTAINER_HOSTNAME' (ID: $CONTAINER_ID, status: $STATUS)." diff --git a/.github/actions/wait-for-job/action.yml b/.github/actions/wait-for-job/action.yml new file mode 100644 index 00000000..87dac4f3 --- /dev/null +++ b/.github/actions/wait-for-job/action.yml @@ -0,0 +1,101 @@ +name: Wait for Job +description: Poll a Create-a-Container API job until it completes, fails, or times out. +author: mieweb + +inputs: + api_url: + description: Base URL for the Container API (e.g. https://containers.example.com). + required: true + api_key: + description: API key used as a Bearer token for authentication. + required: true + job_id: + description: Numeric ID of the job to poll. + required: true + max_attempts: + description: Maximum number of poll attempts before timing out. + required: false + default: "30" + poll_interval: + description: Seconds to wait between poll attempts. + required: false + default: "10" + +outputs: + job_done: + description: "'true' when the job completed successfully." + value: ${{ steps.wait.outputs.job_done }} + +runs: + using: composite + steps: + - name: Poll job until complete + id: wait + shell: bash + env: + API_URL: ${{ inputs.api_url }} + API_KEY: ${{ inputs.api_key }} + JOB_ID: ${{ inputs.job_id }} + MAX_ATTEMPTS: ${{ inputs.max_attempts }} + POLL_INTERVAL: ${{ inputs.poll_interval }} + run: | + set -uo pipefail + + if [ -z "$JOB_ID" ] || [ "$JOB_ID" == "null" ]; then + echo "::error::wait-for-job called with an empty job_id." + exit 1 + fi + + ATTEMPT=0 + echo "Waiting for job $JOB_ID to complete (up to $((MAX_ATTEMPTS * POLL_INTERVAL))s)..." + + while [ "$ATTEMPT" -lt "$MAX_ATTEMPTS" ]; do + ATTEMPT=$((ATTEMPT + 1)) + + RAW=$(curl -s -w "\n%{http_code}" \ + -H "Authorization: Bearer $API_KEY" \ + -H "Accept: application/json" \ + "$API_URL/api/v1/jobs/$JOB_ID") + + HTTP_CODE=$(echo "$RAW" | tail -1) + BODY=$(echo "$RAW" | sed '$d') + + echo " Attempt $ATTEMPT/$MAX_ATTEMPTS — HTTP $HTTP_CODE" + + if [ "$HTTP_CODE" -lt 200 ] || [ "$HTTP_CODE" -ge 300 ]; then + echo " API returned HTTP $HTTP_CODE — retrying." + sleep "$POLL_INTERVAL" + continue + fi + + if ! echo "$BODY" | jq empty 2>/dev/null; then + echo " Response is not valid JSON — retrying." + sleep "$POLL_INTERVAL" + continue + fi + + JOB_STATUS=$(echo "$BODY" | jq -r '.data.status // .status // "unknown"') + echo " Job status: $JOB_STATUS" + + if [ "$JOB_STATUS" == "success" ] || [ "$JOB_STATUS" == "completed" ]; then + echo "Job $JOB_ID completed successfully." + echo "job_done=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + if [ "$JOB_STATUS" == "failed" ] || [ "$JOB_STATUS" == "error" ] || [ "$JOB_STATUS" == "cancelled" ]; then + FAILURE_REASON=$(echo "$BODY" | jq -r '.error // .message // .reason // empty') + if [ -n "$FAILURE_REASON" ]; then + echo "::error::Job $JOB_ID entered '$JOB_STATUS' state: $FAILURE_REASON" + else + echo "::error::Job $JOB_ID entered '$JOB_STATUS' state. Full response: $BODY" + fi + exit 1 + fi + + # Still pending or running — keep polling + sleep "$POLL_INTERVAL" + done + + echo "::error::Timed out waiting for job $JOB_ID after $((MAX_ATTEMPTS * POLL_INTERVAL))s." + exit 1 diff --git a/create-a-container/bin/create-container.js b/create-a-container/bin/create-container.js index 766ed815..5df8813d 100755 --- a/create-a-container/bin/create-container.js +++ b/create-a-container/bin/create-container.js @@ -520,7 +520,7 @@ async function main() { // Register the container in NetBox if the integration is configured await withNetbox(Setting, async (baseUrl, token) => { - console.log(`Registering container in NetBox (cluster: ${site.name})...`); + console.log(`Registering container in NetBox (site: ${site.name})...`); try { await createVirtualMachine(baseUrl, token, { hostname: container.hostname, diff --git a/create-a-container/utils/netbox.js b/create-a-container/utils/netbox.js index 70d7dcdc..1661615f 100644 --- a/create-a-container/utils/netbox.js +++ b/create-a-container/utils/netbox.js @@ -17,6 +17,10 @@ const NETBOX_COMMENT = 'This container was built using opensource-server'; +// NetBox stores the VM `disk` field in megabytes and divides by 1000 (decimal) +// for display, so convert gigabytes to MB before sending. +const MB_PER_GB = 1000; + /** * Build request headers for NetBox API calls. * @param {string} token - NetBox API token @@ -85,12 +89,11 @@ async function findDeviceId(baseUrl, token, deviceName) { } /** - * Look up a NetBox cluster by name. + * Look up a NetBox cluster by name. Returns null if not found. * @param {string} baseUrl * @param {string} token * @param {string} clusterName - Should match the Site.name value - * @returns {Promise} Cluster ID - * @throws {Error} If the cluster is not found in NetBox + * @returns {Promise} Cluster ID or null */ async function findClusterId(baseUrl, token, clusterName) { const data = await nbFetch( @@ -98,17 +101,15 @@ async function findClusterId(baseUrl, token, clusterName) { token, `/virtualization/clusters/?name=${encodeURIComponent(clusterName)}&limit=1`, ); - if (!data?.results?.length) { - throw new Error(`NetBox: cluster "${clusterName}" not found`); - } - return data.results[0].id; + return data?.results?.[0]?.id ?? null; } /** * Create a virtual machine record in NetBox for a newly provisioned container. * * Steps: - * 1. Resolve cluster ID from site name + * 1. Resolve cluster and/or site ID from the site name (NetBox 3.3+ allows a + * VM to be assigned to a site without a cluster) * 2. Create the VM record * 3. Create an eth0 interface on the VM * 4. Create an IP address assigned to that interface @@ -118,7 +119,7 @@ async function findClusterId(baseUrl, token, clusterName) { * @param {string} token * @param {object} opts * @param {string} opts.hostname - Container hostname (becomes VM name) - * @param {string} opts.clusterName - Site name used to resolve the NetBox cluster + * @param {string} opts.clusterName - Site name used to resolve the NetBox cluster and/or site * @param {string} opts.ipv4Address - Container IPv4 address (CIDR or bare IP) * @param {string} [opts.createdBy] - Username of the person who created the container * @param {string} [opts.nodeName] - Proxmox node name; mapped to the NetBox device within the cluster @@ -133,20 +134,25 @@ async function createVirtualMachine(baseUrl, token, { hostname, clusterName, ipv findSiteId(baseUrl, token, clusterName), nodeName ? findDeviceId(baseUrl, token, nodeName) : Promise.resolve(null), ]); + // A NetBox VM must be anchored to a cluster or a site. If neither matches the + // site name, fail loudly so the misconfiguration is visible. + if (clusterId === null && siteId === null) { + throw new Error(`NetBox: no cluster or site named "${clusterName}" found`); + } const comment = createdBy ? `${NETBOX_COMMENT}\nCreated by: ${createdBy}` : NETBOX_COMMENT; const vmBody = { name: hostname, - cluster: clusterId, status: 'active', comments: comment, + ...(clusterId !== null && { cluster: clusterId }), ...(siteId !== null && { site: siteId }), ...(deviceId !== null && { device: deviceId }), ...(vcpus != null && { vcpus }), ...(memoryMb != null && { memory: memoryMb }), - ...(diskGb != null && { disk: diskGb }), + ...(diskGb != null && { disk: diskGb * MB_PER_GB }), }; const vm = await nbFetch(baseUrl, token, '/virtualization/virtual-machines/', { @@ -211,7 +217,7 @@ async function updateVirtualMachine(baseUrl, token, hostname, { vcpus, memoryMb, const patch = { ...(vcpus != null && { vcpus }), ...(memoryMb != null && { memory: memoryMb }), - ...(diskGb != null && { disk: diskGb }), + ...(diskGb != null && { disk: diskGb * MB_PER_GB }), }; if (Object.keys(patch).length === 0) return; await nbFetch(baseUrl, token, `/virtualization/virtual-machines/${vm.id}/`, {