From 9adb5780f27f0ce556e6c77cd102c7497f6041eb Mon Sep 17 00:00:00 2001 From: Nathanial Lubitz Date: Mon, 29 Jun 2026 16:01:01 +1000 Subject: [PATCH] use as gitea action --- Dockerfile | 21 +-- action.yml | 71 +++------- entrypoint.sh | 212 ++++++++++------------------ internal/config/defaults.go | 6 + internal/config/types.go | 6 + internal/generator/gitea_actions.go | 168 +++++++++++++++------- 6 files changed, 236 insertions(+), 248 deletions(-) diff --git a/Dockerfile b/Dockerfile index 4ed7c52..fca8605 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,16 +1,19 @@ FROM golang:1.22-alpine AS builder -WORKDIR /app +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download COPY . . -RUN go build -o kforge . +RUN go build -o /usr/local/bin/kforge . -FROM alpine:3.19 -COPY --from=builder /app/kforge /usr/local/bin/kforge -RUN apk add --no-cache curl docker-cli && \ - curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl" && \ - install -o root -g root -m 0755 kubectl /usr/local/bin/kubectl && \ - curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin +FROM alpine:3.20 +RUN apk add --no-cache ca-certificates curl git +ARG KUBECTL_VERSION=v1.31.0 +RUN curl -fsSL "https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/amd64/kubectl" \ + -o /usr/local/bin/kubectl && chmod +x /usr/local/bin/kubectl + +COPY --from=builder /usr/local/bin/kforge /usr/local/bin/kforge COPY entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh -ENTRYPOINT ["/entrypoint.sh"] \ No newline at end of file +ENTRYPOINT ["/entrypoint.sh"] diff --git a/action.yml b/action.yml index 8ded03d..dd00b4b 100644 --- a/action.yml +++ b/action.yml @@ -1,62 +1,29 @@ -name: "K8s YAML Generator" -description: "Builds a Docker image, pushes it to a private registry, generates Kubernetes YAML from a simplified YML file, and deploys it." -author: "Claude Code made this" +name: 'kforge' +description: 'Generate and apply Kubernetes manifests from kforge.yml' inputs: - image_name: - description: "Docker image name to build and push (e.g. my-app)" - required: true - image_tag: - description: "Docker image tag. If omitted, defaults to both 'latest' and the short commit SHA." + command: + description: 'deploy | preview-up | preview-down | validate | secrets' required: false - dockerfile: - description: "Path to Dockerfile" + default: 'deploy' + env: + description: 'Environment to target (e.g. production, staging). Omit to target all.' required: false - default: "Dockerfile" - max_tags: - description: "Maximum number of SHA image tags to keep in the registry" + config: + description: 'Path to kforge.yml relative to the workspace root' required: false - default: "5" - - registry: - description: "Docker registry URL" + default: 'kforge.yml' + namespace: + description: 'Kubernetes namespace for rollout restart (defaults to env name)' required: false - default: "registry.natelubitz.com" - registry_username: - description: "Registry username" - required: true - registry_password: - description: "Registry password" - required: true - - kube_host: - description: "Kubernetes API server URL" + pr_number: + description: 'PR number — required for preview-up and preview-down' required: false - default: "192.168.1.20:16443" - kube_certificate: - description: "Base64 encoded Kubernetes CA certificate" - required: true - kube_token: - description: "Kubernetes service account token" - required: true - - scan_image: - description: "Scan image for vulnerabilities before pushing" + namespace_prefix: + description: 'Namespace prefix for preview environments' required: false - default: "true" - scan_severity: - description: "Fail on these severity levels (UNKNOWN,LOW,MEDIUM,HIGH,CRITICAL)" - required: false - default: "HIGH,CRITICAL" - -# outputs: -# output_file: -# description: "Path to the generated Kubernetes YAML file" + default: 'preview-pr' runs: - using: "docker" - image: "docker://registry.natelubitz.com/infra/kforge:latest" - # args: - # - ${{ inputs.input_file }} - # - ${{ inputs.output_file }} - # - ${{ inputs.auto_deploy }} + using: docker + image: Dockerfile diff --git a/entrypoint.sh b/entrypoint.sh index 455aa8d..70417e3 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -1,146 +1,90 @@ #!/bin/sh set -e -# INPUT_FILE="$1" -# OUTPUT_FILE="$2" -# AUTO_DEPLOY="$3" +COMMAND="${INPUT_COMMAND:-deploy}" +CONFIG="${INPUT_CONFIG:-kforge.yml}" -# ---------------------------------------------------------------- -# Registry login -# ---------------------------------------------------------------- -if [ -n "$INPUT_REGISTRY_USERNAME" ] && [ -n "$INPUT_REGISTRY_PASSWORD" ]; then - echo "Logging in to $INPUT_REGISTRY..." - echo "$INPUT_REGISTRY_PASSWORD" | docker login "$INPUT_REGISTRY" \ - -u "$INPUT_REGISTRY_USERNAME" --password-stdin -fi - -# ---------------------------------------------------------------- -# Build and push image -# ---------------------------------------------------------------- -cleanup_old_tags() { - IMAGE="$1" - KEEP="$2" - - echo "Fetching tags for $IMAGE..." - - TAGS=$(curl -s -u "$INPUT_REGISTRY_USERNAME:$INPUT_REGISTRY_PASSWORD" \ - "https://$INPUT_REGISTRY/v2/$IMAGE/tags/list" \ - | tr ',' '\n' \ - | grep -o '"[a-f0-9]\{7\}"' \ - | tr -d '"') - - COUNT=$(echo "$TAGS" | grep -c .) - DELETE_COUNT=$((COUNT - KEEP)) - - if [ "$DELETE_COUNT" -le 0 ]; then - echo "Only $COUNT hash tags found, no cleanup needed." - return - fi - - echo "Found $COUNT hash tags, deleting oldest $DELETE_COUNT..." - - echo "$TAGS" | head -n "$DELETE_COUNT" | while read -r TAG; do - echo "Deleting tag: $TAG..." - - DIGEST=$(curl -s -I \ - -u "$INPUT_REGISTRY_USERNAME:$INPUT_REGISTRY_PASSWORD" \ - -H "Accept: application/vnd.docker.distribution.manifest.v2+json" \ - "https://$INPUT_REGISTRY/v2/$IMAGE/manifests/$TAG" \ - | grep -i "docker-content-digest" \ - | tr -d '\r' \ - | awk '{print $2}') - - if [ -n "$DIGEST" ]; then - curl -s -X DELETE \ - -u "$INPUT_REGISTRY_USERNAME:$INPUT_REGISTRY_PASSWORD" \ - "https://$INPUT_REGISTRY/v2/$IMAGE/manifests/$DIGEST" - echo "Deleted $TAG ($DIGEST)" - else - echo "Could not find digest for $TAG, skipping." - fi - done +# Build a kubeconfig from the standard KUBE_* CI secrets. +setup_kube() { + [ -z "$KUBE_HOST" ] && return + mkdir -p ~/.kube + cat > ~/.kube/config < /tmp/kube-ca.crt 2>&1 -echo "Cert file size: $(wc -c < /tmp/kube-ca.crt)" -echo "Cert file contents: $(cat /tmp/kube-ca.crt | head -1)" - -kubectl config set-cluster default \ ---server="$INPUT_KUBE_HOST" \ ---certificate-authority=/tmp/kube-ca.crt - -kubectl config set-credentials default \ ---token="$INPUT_KUBE_TOKEN" - -kubectl config set-context default \ ---cluster=default \ ---user=default - -kubectl config use-context default - - -# Create/update regcred secret idempotently -# echo "Creating regcred secret..." -# kubectl create secret docker-registry regcred \ -# --docker-server="$INPUT_REGISTRY" \ -# --docker-username="$INPUT_REGISTRY_USERNAME" \ -# --docker-password="$INPUT_REGISTRY_PASSWORD" \ -# --dry-run=client -o yaml | kubectl apply -f - --insecure-skip-tls-verify --validate=false - -echo "Deploying to Kubernetes..." -kubectl apply --insecure-skip-tls-verify --validate=false -f ./.kforge-out/ -echo "Deploy complete." -echo "Cleanup" -rm -f /tmp/kube-ca.crt + *) + echo "::error::Unknown command '$COMMAND'. Valid: deploy, preview-up, preview-down, validate, secrets" + exit 1 + ;; +esac diff --git a/internal/config/defaults.go b/internal/config/defaults.go index cffda09..d86098d 100644 --- a/internal/config/defaults.go +++ b/internal/config/defaults.go @@ -2,6 +2,7 @@ package config import ( "fmt" + "os" "strings" "kforge/pkg/interpolate" @@ -306,6 +307,11 @@ func ResolveEnvironment(cfg *KforgeConfig, envKey string) (ResolvedEnvironment, if imageTag == "" { imageTag = DefaultImageTag } + // Allow the action entrypoint (or CI) to override the image tag at generate + // time without modifying kforge.yml (e.g. KFORGE_IMAGE_TAG=abc1234). + if override := os.Getenv("KFORGE_IMAGE_TAG"); override != "" { + imageTag = override + } image := registry.URL + "/" + registry.Repository + ":" + imageTag replicas := *cfg.Defaults.Replicas diff --git a/internal/config/types.go b/internal/config/types.go index a683cb4..96cb70a 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -14,6 +14,12 @@ type KforgeConfig struct { Infrastructure InfrastructureConfig `yaml:"infrastructure"` Preview PreviewConfig `yaml:"preview,omitempty"` Environments map[string]EnvironmentConfig `yaml:"environments"` + + // ActionRef is the Gitea Actions reference to this kforge installation + // (e.g. "gitea.example.com/infra/kforge@main"). When set, kforge gitea-actions + // and kforge gitea-preview generate workflows that call kforge as a reusable + // action step instead of installing and running kforge inline. + ActionRef string `yaml:"action_ref,omitempty"` } // ------------------------------------------------------------ diff --git a/internal/generator/gitea_actions.go b/internal/generator/gitea_actions.go index 1b53e6e..960fc4e 100644 --- a/internal/generator/gitea_actions.go +++ b/internal/generator/gitea_actions.go @@ -74,10 +74,6 @@ func writeDeployJobs(b *strings.Builder, cfg *config.KforgeConfig, opts GiteaAct "with": map[string]any{"fetch-depth": 0}, }) - writeStep(b, "Create short commit hash", map[string]any{ - "run": `echo "SHORT_SHA=$(git rev-parse --short HEAD)" >> $GITHUB_ENV`, - }) - writeStep(b, "Login to registry", map[string]any{ "uses": "docker/login-action@v2", "with": map[string]any{ @@ -101,6 +97,39 @@ func writeDeployJobs(b *strings.Builder, cfg *config.KforgeConfig, opts GiteaAct }, }) + if cfg.ActionRef != "" { + writeActionDeploySteps(b, cfg, opts) + } else { + writeInlineDeploySteps(b, cfg, opts) + } +} + +// writeActionDeploySteps emits one `uses: action_ref` step per environment. +func writeActionDeploySteps(b *strings.Builder, cfg *config.KforgeConfig, opts GiteaActionsOptions) { + for _, envKey := range opts.Environments { + env, err := config.ResolveEnvironment(cfg, envKey) + if err != nil { + continue + } + label := strings.Title(envKey) //nolint:staticcheck + writeStep(b, fmt.Sprintf("Deploy (%s)", label), map[string]any{ + "uses": cfg.ActionRef, + "with": map[string]any{ + "command": "deploy", + "env": envKey, + "namespace": env.Namespace, + }, + "env": actionEnv(), + }) + } +} + +// writeInlineDeploySteps emits the classic multi-step inline approach. +func writeInlineDeploySteps(b *strings.Builder, cfg *config.KforgeConfig, opts GiteaActionsOptions) { + writeStep(b, "Create short commit hash", map[string]any{ + "run": `echo "SHORT_SHA=$(git rev-parse --short HEAD)" >> $GITHUB_ENV`, + }) + writeStep(b, "Install kforge", map[string]any{ "run": "KFORGE_VERSION=\"latest\"\ncurl -fsSL \"https://kforge/releases/download/${KFORGE_VERSION}/kforge-linux-amd64\" -o /usr/local/bin/kforge\nchmod +x /usr/local/bin/kforge", }) @@ -244,60 +273,82 @@ func GeneratePreviewActions(cfg *config.KforgeConfig) (string, error) { }, }) - writeStep(&b, "Install kforge", map[string]any{ - "if": "${{ github.event.action != 'closed' }}", - "run": "KFORGE_VERSION=\"latest\"\ncurl -fsSL \"https://kforge/releases/download/${KFORGE_VERSION}/kforge-linux-amd64\" -o /usr/local/bin/kforge\nchmod +x /usr/local/bin/kforge", - }) + if cfg.ActionRef != "" { + writeStep(&b, "Deploy preview", map[string]any{ + "if": "${{ github.event.action != 'closed' }}", + "uses": cfg.ActionRef, + "with": map[string]any{ + "command": "preview-up", + "pr_number": "${{ github.event.number }}", + "namespace_prefix": nsPrefix, + }, + "env": actionEnv(), + }) - writeStep(&b, "Apply preview secrets", map[string]any{ - "if": "${{ github.event.action != 'closed' }}", - "run": "kforge secrets apply --pr-number ${{ github.event.number }}", - "env": giteaKubeEnv(), - }) + writeStep(&b, "Destroy preview", map[string]any{ + "if": "${{ github.event.action == 'closed' }}", + "uses": cfg.ActionRef, + "with": map[string]any{ + "command": "preview-down", + "pr_number": "${{ github.event.number }}", + "namespace_prefix": nsPrefix, + }, + "env": actionEnv(), + }) + } else { + writeStep(&b, "Install kforge", map[string]any{ + "if": "${{ github.event.action != 'closed' }}", + "run": "KFORGE_VERSION=\"latest\"\ncurl -fsSL \"https://kforge/releases/download/${KFORGE_VERSION}/kforge-linux-amd64\" -o /usr/local/bin/kforge\nchmod +x /usr/local/bin/kforge", + }) - writeStep(&b, "Generate preview manifests", map[string]any{ - "if": "${{ github.event.action != 'closed' }}", - "run": "kforge generate --pr-number ${{ github.event.number }} --output .kforge-out", - "env": map[string]any{ - "KFORGE_NODE_IP": "${{ secrets.KFORGE_NODE_IP }}", - "PR_NUMBER": "${{ github.event.number }}", - }, - }) + writeStep(&b, "Apply preview secrets", map[string]any{ + "if": "${{ github.event.action != 'closed' }}", + "run": "kforge secrets apply --pr-number ${{ github.event.number }}", + "env": giteaKubeEnv(), + }) - writeStep(&b, "Apply preview manifests", map[string]any{ - "if": "${{ github.event.action != 'closed' }}", - "uses": "actions-hub/kubectl@master", - "env": giteaKubeEnv(), - "with": map[string]any{ - "args": fmt.Sprintf( - "apply -f .kforge-out/ --insecure-skip-tls-verify", - ), - }, - }) + writeStep(&b, "Generate preview manifests", map[string]any{ + "if": "${{ github.event.action != 'closed' }}", + "run": "kforge generate --pr-number ${{ github.event.number }} --output .kforge-out", + "env": map[string]any{ + "KFORGE_NODE_IP": "${{ secrets.KFORGE_NODE_IP }}", + "PR_NUMBER": "${{ github.event.number }}", + }, + }) - writeStep(&b, "Wait for preview rollout", map[string]any{ - "if": "${{ github.event.action != 'closed' }}", - "uses": "actions-hub/kubectl@master", - "env": giteaKubeEnv(), - "with": map[string]any{ - "args": fmt.Sprintf( - "rollout status deployment -n %s-${{ github.event.number }} --timeout=120s --insecure-skip-tls-verify", - nsPrefix, - ), - }, - }) + writeStep(&b, "Apply preview manifests", map[string]any{ + "if": "${{ github.event.action != 'closed' }}", + "uses": "actions-hub/kubectl@master", + "env": giteaKubeEnv(), + "with": map[string]any{ + "args": "apply -f .kforge-out/ --insecure-skip-tls-verify", + }, + }) - writeStep(&b, "Destroy preview namespace", map[string]any{ - "if": "${{ github.event.action == 'closed' }}", - "uses": "actions-hub/kubectl@master", - "env": giteaKubeEnv(), - "with": map[string]any{ - "args": fmt.Sprintf( - "delete namespace %s-${{ github.event.number }} --ignore-not-found --insecure-skip-tls-verify", - nsPrefix, - ), - }, - }) + writeStep(&b, "Wait for preview rollout", map[string]any{ + "if": "${{ github.event.action != 'closed' }}", + "uses": "actions-hub/kubectl@master", + "env": giteaKubeEnv(), + "with": map[string]any{ + "args": fmt.Sprintf( + "rollout status deployment -n %s-${{ github.event.number }} --timeout=120s --insecure-skip-tls-verify", + nsPrefix, + ), + }, + }) + + writeStep(&b, "Destroy preview namespace", map[string]any{ + "if": "${{ github.event.action == 'closed' }}", + "uses": "actions-hub/kubectl@master", + "env": giteaKubeEnv(), + "with": map[string]any{ + "args": fmt.Sprintf( + "delete namespace %s-${{ github.event.number }} --ignore-not-found --insecure-skip-tls-verify", + nsPrefix, + ), + }, + }) + } return b.String(), nil } @@ -383,6 +434,17 @@ func giteaKubeEnv() map[string]any { } } +// actionEnv returns the combined env block for a kforge action step — +// kubectl auth plus the node IP for external-dns annotation resolution. +func actionEnv() map[string]any { + return map[string]any{ + "KUBE_CERTIFICATE": "${{ secrets.KUBE_CERTIFICATE }}", + "KUBE_HOST": "${{ secrets.KUBE_HOST }}", + "KUBE_TOKEN": "${{ secrets.KUBE_TOKEN }}", + "KFORGE_NODE_IP": "${{ secrets.KFORGE_NODE_IP }}", + } +} + func mergeMaps(maps ...map[string]any) map[string]any { result := map[string]any{} for _, m := range maps {