Manage jobs, workflows, and webhooks natively via K8s APIs.
No dependencies, no virtual machines, no Docker-in-Docker, no need to learn a new language or framework. Just standard K8s Jobs.
Important
Upgrading from a previous version? Check the Migration Guide.
helm upgrade --install \
--create-namespace \
--namespace simple-cicd \
--repo https://jlsalvador.github.io/simple-cicd \
operator operatorkubectl apply -f https://github.com/jlsalvador/simple-cicd/releases/latest/download/crds.yaml
kubectl apply -f https://github.com/jlsalvador/simple-cicd/releases/latest/download/operator.yamlThis example creates a workflow that runs a job, echoing the original HTTP request and returning a random exit code. On failure, it triggers a second workflow that echoes "ERROR".
Caution
Job templates must have spec.suspend: true. Without it,
Kubernetes will start the Job immediately. The operator automatically sets
spec.suspend: false.
# Job that randomly exits with code 0 or 1.
apiVersion: batch/v1
kind: Job
metadata:
name: job-example-random-exit
namespace: example
spec:
suspend: true # Prevents Kubernetes from running this directly.
backoffLimit: 0
template:
spec:
containers:
- name: random-exit
image: bash
command: ["sh", "-c", "exit $$(($RANDOM % 2))"]
- name: echo-request
image: bash
command:
- sh
- -c
- |
echo "Host: $(cat /var/run/secrets/kubernetes.io/request/host)"
echo "Headers: $(cat /var/run/secrets/kubernetes.io/request/headers)"
echo "Method: $(cat /var/run/secrets/kubernetes.io/request/method)"
echo "URL: $(cat /var/run/secrets/kubernetes.io/request/url)"
echo "RemoteAddr: $(cat /var/run/secrets/kubernetes.io/request/remoteAddr)"
echo "Timestamp: $(cat /var/run/secrets/kubernetes.io/request/timestamp)"
echo "Body: $(cat /var/run/secrets/kubernetes.io/request/body)"
restartPolicy: Never # Do not re-run the pod if something fails.
---
# Job that echoes "ERROR".
apiVersion: batch/v1
kind: Job
metadata:
name: job-example-error
namespace: example
spec:
suspend: true
template:
spec:
containers:
- name: error
image: bash
command: ["echo", "ERROR"]
restartPolicy: Never
---
# Workflow triggered on failure: runs job-example-error.
apiVersion: simple-cicd.jlsalvador.online/v1alpha2
kind: Workflow
metadata:
name: workflow-example-on-failure
namespace: example
spec:
jobsToBeCloned:
- name: job-example-error
---
# Main workflow: runs job-example-random-exit, then workflow-example-on-failure on any failure.
apiVersion: simple-cicd.jlsalvador.online/v1alpha2
kind: Workflow
metadata:
name: workflow-example
namespace: example
spec:
jobsToBeCloned:
- name: job-example-random-exit
next:
- name: workflow-example-on-failure
when: OnAnyFailure
---
# WorkflowWebhook: listens on /example/workflowwebhook-example.
apiVersion: simple-cicd.jlsalvador.online/v1alpha2
kind: WorkflowWebhook
metadata:
name: workflowwebhook-example
namespace: example
spec:
workflows:
- name: workflow-example
ttlSecondsAfterFinished: 3600 # Clean up WWRs after 1 hour.Trigger the WorkflowWebhook via kubectl port-forward:
kubectl -n simple-cicd port-forward svc/operator 9000:9000 &
curl -XPOST http://localhost:9000/example/workflowwebhook-exampleTip
You can also reach the operator directly from within the cluster or expose it through any Service. Remember to set up proper authentication & authorization.
Every Job cloned by the operator has the original HTTP request data mounted as
read-only files at /var/run/secrets/kubernetes.io/request/ inside all its
containers.
| File | Content |
|---|---|
body |
Request body |
headers |
All headers serialised as JSON |
host |
Host header value |
method |
HTTP method (GET, POST, β¦) |
url |
Full request URL |
remoteAddr |
Client IP and port (e.g. 10.0.0.5:54321) |
timestamp |
Time the request was received (UNIX timestamp) |
Simple CI/CD follows the Kubernetes Operator pattern.
The operator exposes an HTTP server on port 9000. Each incoming request to
/{namespace}/{workflowWebhookName} creates a WorkflowWebhookRequest and
starts the reconciliation loop.
flowchart TD
%% Style Definitions
classDef trigger fill:#e3f2fd,stroke:#1e88e5,stroke-width:2px,color:#000
classDef k8s fill:#e8f5e9,stroke:#43a047,stroke-width:2px,color:#000
classDef secret fill:#fff3e0,stroke:#fb8c00,stroke-width:2px,color:#000
classDef decision fill:#fff9c4,stroke:#fbc02d,stroke-width:2px,color:#000
classDef endState fill:#ffebee,stroke:#e53935,stroke-width:2px,color:#000
classDef success fill:#c8e6c9,stroke:#2e7d32,stroke-width:2px,color:#000
classDef failure fill:#ffccbc,stroke:#d84315,stroke-width:2px,color:#000
A["π HTTP POST<br>/{namespace}/{webhookName}"]:::trigger
subgraph Phase1 ["π₯ Request"]
B["π WorkflowWebhookRequest"]:::k8s
end
subgraph Phase2 ["βοΈ Execution"]
D["π WorkflowWebhook"]:::k8s
E["π Workflow"]:::k8s
C[("π Request Secret")]:::secret
F["ποΈ Jobs"]:::k8s
end
subgraph Phase3 ["π¦ Evaluation & Control"]
G{"ποΈ watch Job status"}:::decision
H["β
next Workflow(s)<br>when: OnSuccess / Always"]:::success
I["β next Workflow(s)<br>when: OnFailure / Always"]:::failure
J{"π more Workflows?"}:::decision
end
K(["π WorkflowWebhookRequest done"]):::endState
%% Connections
A --> B
A --> C
B -->|reads| D
D --> E
%% Dependency Injection: Secret mounted into Pods
C -.->|mounted into every Job pod| F
E -->|clone| F
F --> G
G -->|success| H
G -->|failure| I
H --> J
I --> J
%% Loops and Termination
J -->|no| K
J -->|yes| E
- A webhook HTTP request arrives. The operator creates a
Secretcontaining the request data and aWorkflowWebhookRequest(WWR) referencing it. - The reconciler reads the WWR, resolves the referenced
WorkflowWebhookand itsWorkflowlist. - For each active Workflow, referenced Jobs are cloned. Jobs in the same namespace as the WWR mount the request Secret directly. Jobs in other namespaces receive a per-job mirrored copy of the Secret, owned by the Job and automatically garbage-collected when the Job is deleted.
- The reconciler polls cloned Jobs until all finish, then evaluates
whenconditions to determine which next Workflows to trigger. - Steps 3-4 repeat until no further Workflows remain, at which point the WWR
is marked
done: true.
Defines which Jobs to clone and which Workflows to trigger next based on exit status.
apiVersion: simple-cicd.jlsalvador.online/v1alpha2
kind: Workflow
metadata:
name: my-workflow
namespace: example
spec:
jobsToBeCloned:
- name: my-job
namespace: example # Defaults to the Workflow's own namespace when omitted.
next:
- name: my-next-workflow
when: OnAnyFailure # OnSuccess | OnAnySuccess | OnFailure | OnAnyFailure | Always
suspend: false # Set to true to skip execution without deleting.Binds an HTTP path to one or more Workflows and controls concurrency and
lifecycle policies. All policy fields are copied into each
WorkflowWebhookRequest at creation time and are not affected by later changes
to the WorkflowWebhook.
apiVersion: simple-cicd.jlsalvador.online/v1alpha2
kind: WorkflowWebhook
metadata:
name: my-webhook
namespace: example
spec:
workflows:
- name: my-workflow
namespace: example # Defaults to the WorkflowWebhook's own namespace when omitted.
concurrencyPolicy: Allow # Allow | Forbid | Replace
suspend: false
ttlSecondsAfterFinished: 3600 # Delete the WWR 1 hour after it finishes. Omit to keep it forever.
activeDeadlineSeconds: 1800 # Mark the WWR done after 30 min if still running. Omit to disable.| Policy | Behaviour |
|---|---|
Allow |
Multiple WWRs can run simultaneously (default). |
Forbid |
Creates the WWR without executing any Workflow. |
Replace |
Deletes any running WWR and starts a fresh one. |
Note
With Forbid, the HTTP response is still 202 Accepted. The WWR
is created and immediately marked done. Inspect status.conditions to
determine whether execution was actually skipped.
| Value | Trigger condition |
|---|---|
OnSuccess |
All Jobs in the step succeeded (default) |
OnAnySuccess |
At least one Job succeeded |
OnFailure |
All Jobs in the step failed |
OnAnyFailure |
At least one Job failed |
Always |
Always trigger regardless of outcome |
When set, the operator automatically deletes the WWR the specified number of
seconds after it completes. 0 means delete immediately on completion. Omitting
the field keeps the WWR indefinitely.
When set, the operator forcibly terminates all running Jobs and marks the WWR
done with reason DeadlineExceeded if it has not completed within the specified
number of seconds of its creation. Omitting the field disables the deadline.
Note
activeDeadlineSeconds is measured from metadata.creationTimestamp, not
from when execution actually started, making it a hard wall-clock limit on
the total time a request may occupy the system.
Created automatically by the operator on each incoming HTTP request. Tracks the full execution lifecycle.
kubectl get wwr -n example
NAME DONE WEBHOOK SUCCESSFUL JOBS FAILED JOBS AGE
workflowwebhook-example-b5lmh true workflowwebhook-example 1 0 76s
workflowwebhook-example-lw2ws true workflowwebhook-example 1 1 12mThe status.conditions field records lifecycle events. The most recent
condition reflects the final outcome:
reason |
Meaning |
|---|---|
Done |
All Workflows completed normally. |
Suspended |
No Workflows were executed. |
Forbidden |
Another WWR was running. |
NoWorkflows |
Nothing to execute. |
DeadlineExceeded |
Running Jobs were terminated. |
kubectl get wwr -n example -o jsonpath='{.items[-1].status.conditions[-1]}'"Simple CI/CD doesn't try to compete with GitHub Actions in features; it competes in simplicity and low overhead."
Existing solutions either impose excessive requirements or require virtual machines, Docker-in-Docker, or components outside the Kubernetes ecosystem.
Simple CI/CD uses native Kubernetes Jobs, so you do not need to learn a new language.
| Alternative | Advantages | Disadvantages |
|---|---|---|
| GitHub Actions |
|
|
| Jenkins |
|
|
| Tekton |
|
|
| Drone |
|
|
| Woodpecker CI |
|
|
| Gitea act_runner |
|
|
| Simple CI/CD |
|
|
Copyright 2023 JosΓ© Luis Salvador Rufo salvador.joseluis@gmail.com.
Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
