Skip to content
Merged
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/actions/README.md
Original file line number Diff line number Diff line change
@@ -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.
Comment thread
runleveldev marked this conversation as resolved.
126 changes: 126 additions & 0 deletions .github/actions/create-container/action.yml
Original file line number Diff line number Diff line change
@@ -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
52 changes: 52 additions & 0 deletions .github/actions/delete-container/action.yml
Original file line number Diff line number Diff line change
@@ -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
95 changes: 95 additions & 0 deletions .github/actions/get-container/action.yml
Original file line number Diff line number Diff line change
@@ -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)."
Loading
Loading