diff --git a/.github/workflows/build-image.yaml b/.github/workflows/build-image.yaml new file mode 100644 index 0000000..a87dd24 --- /dev/null +++ b/.github/workflows/build-image.yaml @@ -0,0 +1,46 @@ +name: Build and Push Container Image + +on: + push: + branches: [main] + pull_request: + branches: [main] + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=sha + type=raw,value=latest,enable={{is_default_branch}} + + - name: Build and push image + uses: docker/build-push-action@v6 + with: + context: . + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..919e0e7 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,36 @@ +FROM registry.access.redhat.com/ubi9/python-312 + +# Install system dependencies required by operator tooling +USER 0 +RUN dnf install -y \ + git \ + make && \ + # Install Go toolchain + curl -fsSL https://go.dev/dl/go1.23.6.linux-amd64.tar.gz | tar -C /usr/local -xz && \ + # Install GitHub CLI + dnf install -y 'dnf-command(config-manager)' && \ + dnf config-manager --add-repo https://cli.github.com/packages/rpm/gh-cli.repo && \ + dnf install -y gh && \ + dnf clean all + +ENV PATH="/usr/local/go/bin:${PATH}" + +WORKDIR /app + +# Install Python dependencies +COPY server/requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy server code and config +COPY server/server.py server/config.json ./ + +# Copy plugins directory +# server.py resolves: Path(__file__).parent.parent / "plugins" / "oape" +# With __file__=/app/server.py, parent.parent=/, so it expects /plugins/oape +COPY plugins /plugins + +USER 1001 + +EXPOSE 8000 + +CMD ["uvicorn", "server:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/deploy/deployment.yaml b/deploy/deployment.yaml new file mode 100644 index 0000000..ef4d6fa --- /dev/null +++ b/deploy/deployment.yaml @@ -0,0 +1,67 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: oape-server-config +data: + CLAUDE_CODE_USE_VERTEX: "1" + CLOUD_ML_REGION: "us-east5" + ANTHROPIC_VERTEX_PROJECT_ID: "[gcp-project-id]" + ANTHROPIC_MODEL: "claude-opus-4-6" +--- +apiVersion: v1 +kind: Secret +metadata: + name: gcloud-adc-secret +type: Opaque +stringData: + # Replace with ~/.config/gcloud/application_default_credentials.json + # that can authenticate with GCP project. + application_default_credentials.json: '{}' +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: oape-server + labels: + app: oape-server +spec: + replicas: 1 + selector: + matchLabels: + app: oape-server + template: + metadata: + labels: + app: oape-server + spec: + containers: + - name: oape-server + image: ghcr.io/shiftweek/oape-ai-e2e:latest + ports: + - containerPort: 8000 + envFrom: + - configMapRef: + name: oape-server-config + env: + - name: GOOGLE_APPLICATION_CREDENTIALS + value: /secrets/gcloud/application_default_credentials.json + volumeMounts: + - name: gcloud-adc + mountPath: /secrets/gcloud + readOnly: true + volumes: + - name: gcloud-adc + secret: + secretName: gcloud-adc-secret +--- +apiVersion: v1 +kind: Service +metadata: + name: oape-server +spec: + selector: + app: oape-server + ports: + - port: 8000 + targetPort: 8000 + type: ClusterIP diff --git a/server/config.json b/server/config.json new file mode 100644 index 0000000..a984ffa --- /dev/null +++ b/server/config.json @@ -0,0 +1,13 @@ + +{ + "claude_allowed_tools": [ + "Bash(*)", + "Read", + "Write", + "Edit", + "Glob", + "Grep", + "WebFetch", + "Task" + ] +} \ No newline at end of file diff --git a/server/requirements.txt b/server/requirements.txt new file mode 100644 index 0000000..23c01e4 --- /dev/null +++ b/server/requirements.txt @@ -0,0 +1,3 @@ +fastapi>=0.115.0 +uvicorn>=0.32.0 +claude-agent-sdk>=0.1.0 diff --git a/server/server.py b/server/server.py new file mode 100644 index 0000000..6d2a0ff --- /dev/null +++ b/server/server.py @@ -0,0 +1,148 @@ +""" +FastAPI server that exposes the /oape:api-implement Claude Code skill +via the Claude Agent SDK. + +Usage: + uvicorn api.server:app --reload + +Endpoint: + GET /api-implement?ep_url=&cwd= +""" + +import json +import logging +import os +import re +import traceback +from pathlib import Path +from fastapi import FastAPI, HTTPException, Query +from claude_agent_sdk import ( + query, + ClaudeAgentOptions, + AssistantMessage, + ResultMessage, + TextBlock, +) + + +with open("config.json") as cf: + config_json_str = cf.read() +CONFIGS = json.loads(config_json_str) + + +app = FastAPI( + title="OAPE Operator Feature Developer", + description="Invokes the /oape:api-implement Claude Code command to generate " + "controller/reconciler code from an OpenShift enhancement proposal.", + version="0.1.0", +) + +EP_URL_PATTERN = re.compile( + r"^https://github\.com/openshift/enhancements/pull/\d+/?$" +) + +# Resolve the plugin directory (repo root) relative to this file. +# The SDK expects the path to the plugin root (containing .claude-plugin/). +PLUGIN_DIR = str(Path(__file__).resolve().parent.parent / "plugins" / "oape") +print(PLUGIN_DIR) + +CONVERSATION_LOG = Path("/tmp/conversation.log") + +conv_logger = logging.getLogger("conversation") +conv_logger.setLevel(logging.INFO) +_handler = logging.FileHandler(CONVERSATION_LOG) +_handler.setFormatter(logging.Formatter("%(message)s")) +conv_logger.addHandler(_handler) + + +@app.get("/api-implement") +async def api_implement( + ep_url: str = Query( + ..., + description="GitHub PR URL for the OpenShift enhancement proposal " + "(e.g. https://github.com/openshift/enhancements/pull/1234)", + ), + cwd: str = Query( + default="", + description="Absolute path to the operator repository where code " + "will be generated. Defaults to the current working directory.", + ), +): + """Generate controller/reconciler code from an enhancement proposal.""" + + # --- Validate EP URL --- + if not EP_URL_PATTERN.match(ep_url.rstrip("/")): + raise HTTPException( + status_code=400, + detail=( + "Invalid enhancement PR URL. " + "Expected format: https://github.com/openshift/enhancements/pull/" + ), + ) + + # --- Resolve working directory --- + working_dir = cwd if cwd else os.getcwd() + if not os.path.isdir(working_dir): + raise HTTPException( + status_code=400, + detail=f"The provided cwd is not a valid directory: {working_dir}", + ) + + # --- Build SDK options --- + options = ClaudeAgentOptions( + system_prompt=( + "You are an OpenShift operator code generation assistant. " + "Execute the oape:api-implement plugin with the provided EP URL. " + ), + cwd=working_dir, + permission_mode="bypassPermissions", + allowed_tools=CONFIGS['claude_allowed_tools'], + plugins=[{"type": "local", "path": PLUGIN_DIR}], + ) + + # --- Run the agent --- + output_parts: list[str] = [] + conversation: list[dict] = [] + cost_usd = 0.0 + + def _log(role: str, content, **extra): + entry = {"role": role, "content": content, **extra} + conversation.append(entry) + conv_logger.info(f"[{role}] {content}") + + conv_logger.info(f"\n{'=' * 60}\n[request] ep_url={ep_url} cwd={working_dir}\n{'=' * 60}") + + try: + async for message in query( + prompt=f"/oape:api-implement {ep_url}", + options=options, + ): + if isinstance(message, AssistantMessage): + for block in message.content: + if isinstance(block, TextBlock): + output_parts.append(block.text) + _log("assistant", block.text) + else: + _log(f"assistant:{type(block).__name__}", + json.dumps(getattr(block, "__dict__", str(block)), default=str)) + elif isinstance(message, ResultMessage): + cost_usd = message.total_cost_usd + if message.result: + output_parts.append(message.result) + _log("result", message.result, cost_usd=cost_usd) + else: + _log(type(message).__name__, + json.dumps(getattr(message, "__dict__", str(message)), default=str)) + except Exception as exc: + conv_logger.info(f"[error] {traceback.format_exc()}") + raise HTTPException(status_code=500, detail=f"Agent execution failed: {exc}") + + conv_logger.info(f"[done] cost=${cost_usd:.4f} parts={len(output_parts)}\n") + + return { + "status": "success", + "ep_url": ep_url, + "cwd": working_dir, + "output": "\n".join(output_parts), + "cost_usd": cost_usd, + }