Template
feat: add agent boundaries, network policy, agent stub, dockerfile, CI
- AGENT_BOUNDARIES.md: egress allow-list, FS scope, approved/forbidden ops - agent-policy.yaml: k8s NetworkPolicy scoping egress to LiteLLM/brain-mcp/ gitea-mcp/OTLP + default-deny baseline - internal/agent/agent.go: thin ADK runner wrapper (Config + Run) - Dockerfile: distroless multi-stage build, entrypoint cmd/__PROJECT_NAME__ - .gitea/workflows/cd.yml: check → buildah build/push → GitOps deploy Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
name: CD
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
tags: ["v*"]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
env:
|
||||
IMAGE: __PROJECT_NAME__
|
||||
|
||||
jobs:
|
||||
check:
|
||||
name: Lint / Test / Vet
|
||||
runs-on: self-hosted
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: false
|
||||
|
||||
- name: Install toolchain
|
||||
run: |
|
||||
go version
|
||||
curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/HEAD/install.sh \
|
||||
| sh -s -- -b "$(go env GOPATH)/bin" v2.11.4
|
||||
|
||||
- name: Run checks
|
||||
run: task check
|
||||
|
||||
build:
|
||||
name: Build & Import
|
||||
needs: check
|
||||
runs-on: self-hosted
|
||||
if: github.event_name != 'pull_request'
|
||||
outputs:
|
||||
image-tag: ${{ steps.meta.outputs.sha-tag }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Derive image tags
|
||||
id: meta
|
||||
run: |
|
||||
SHA=$(git rev-parse --short HEAD)
|
||||
echo "sha-tag=${SHA}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build and push to local registry
|
||||
run: |
|
||||
REGISTRY="localhost:5000"
|
||||
REF="${REGISTRY}/${{ env.IMAGE }}:${{ steps.meta.outputs.sha-tag }}"
|
||||
buildah build \
|
||||
--label "org.opencontainers.image.revision=${{ github.sha }}" \
|
||||
-t ${REF} \
|
||||
-t ${REGISTRY}/${{ env.IMAGE }}:latest \
|
||||
.
|
||||
buildah push --tls-verify=false ${REF}
|
||||
buildah push --tls-verify=false ${REGISTRY}/${{ env.IMAGE }}:latest
|
||||
echo "✓ Image pushed to ${REF}"
|
||||
|
||||
deploy:
|
||||
name: Deploy via GitOps
|
||||
needs: build
|
||||
runs-on: self-hosted
|
||||
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
|
||||
steps:
|
||||
- name: Update image tag in infra repo
|
||||
env:
|
||||
IMAGE_TAG: ${{ needs.build.outputs.image-tag }}
|
||||
DEPLOY_KEY: ${{ secrets.INFRA_DEPLOY_KEY }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p ~/.ssh
|
||||
echo "$DEPLOY_KEY" > ~/.ssh/id_infra
|
||||
chmod 600 ~/.ssh/id_infra
|
||||
ssh-keyscan -p 30022 10.0.1.20 >> ~/.ssh/known_hosts 2>/dev/null
|
||||
export GIT_SSH_COMMAND="ssh -i ~/.ssh/id_infra -o IdentitiesOnly=yes"
|
||||
rm -rf /tmp/infra
|
||||
git clone -b main ssh://git@10.0.1.20:30022/mathias/infra.git /tmp/infra
|
||||
cd /tmp/infra
|
||||
DEPLOYMENT="k3s/apps/__PROJECT_NAME__/deployment.yaml"
|
||||
sed -i "s|image: localhost:5000/__PROJECT_NAME__:.*|image: localhost:5000/__PROJECT_NAME__:${IMAGE_TAG}|" "$DEPLOYMENT"
|
||||
grep -q "localhost:5000/__PROJECT_NAME__:${IMAGE_TAG}" "$DEPLOYMENT" \
|
||||
|| { echo "✗ image tag patch failed"; exit 1; }
|
||||
if git diff --quiet "$DEPLOYMENT"; then
|
||||
echo "ℹ image tag unchanged — skipping push"
|
||||
else
|
||||
git -c user.name="__PROJECT_NAME__ CI" \
|
||||
-c user.email="ci@__PROJECT_NAME__.local" \
|
||||
commit -m "chore(deploy): __PROJECT_NAME__ → ${IMAGE_TAG}" "$DEPLOYMENT"
|
||||
git push origin main
|
||||
echo "✓ pushed to infra repo"
|
||||
fi
|
||||
shred -u ~/.ssh/id_infra
|
||||
|
||||
- name: Trigger Flux reconcile
|
||||
run: |
|
||||
kubectl -n flux-system annotate gitrepository flux-system \
|
||||
reconcile.fluxcd.io/requestedAt="$(date +%s)" --overwrite
|
||||
kubectl -n flux-system annotate kustomization apps \
|
||||
reconcile.fluxcd.io/requestedAt="$(date +%s)" --overwrite
|
||||
|
||||
- name: Verify rollout
|
||||
run: |
|
||||
kubectl rollout status deployment/__PROJECT_NAME__ \
|
||||
--namespace __PROJECT_NAME__ \
|
||||
--timeout=120s \
|
||||
|| {
|
||||
kubectl get pods -n __PROJECT_NAME__ -o wide
|
||||
kubectl get events -n __PROJECT_NAME__ --sort-by='.lastTimestamp' | tail -20
|
||||
exit 1
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
# Agent boundaries — __PROJECT_NAME__
|
||||
|
||||
Operational scope for this agent. Read before extending tools, adding endpoints,
|
||||
or wiring new data sources. Defaults are conservative; widen explicitly.
|
||||
|
||||
## Network scope
|
||||
|
||||
Egress is allow-listed. The agent MAY reach:
|
||||
|
||||
| Endpoint | Purpose | Protocol | Default port |
|
||||
|--------------------------------|----------------------------------|----------|--------------|
|
||||
| `llm-api.d-ma.be` (LiteLLM) | Model inference | HTTPS | 443 |
|
||||
| `brain-mcp.d-ma.be` | Knowledge base (BM25 + synth) | HTTPS | 443 |
|
||||
| `gitea-mcp.d-ma.be` | Repo/issue/PR ops | HTTPS | 443 |
|
||||
| `jaeger.d-ma.be` | OTLP trace export | HTTP | 4318 |
|
||||
| In-cluster DNS (`kube-dns`) | Service discovery | UDP | 53 |
|
||||
|
||||
Egress MUST be blocked to:
|
||||
|
||||
- Public Slack / Discord / Telegram / email
|
||||
- Third-party LLM APIs (OpenAI, Anthropic, Google) unless explicitly added below
|
||||
- Public package registries from runtime (proxy through build only)
|
||||
- Customer/client domains not listed in the engagement scope
|
||||
|
||||
Add new endpoints by editing `agent-policy.yaml` AND this table in the same commit.
|
||||
|
||||
## File scope
|
||||
|
||||
Read scope:
|
||||
|
||||
- Mounted ConfigMaps and Secrets under `/etc/__PROJECT_NAME__/`
|
||||
- Working dir under `/var/lib/__PROJECT_NAME__/` (ephemeral)
|
||||
- `/tmp` for streaming artifacts (size-capped via emptyDir)
|
||||
|
||||
Write scope:
|
||||
|
||||
- `/var/lib/__PROJECT_NAME__/` only
|
||||
- `/tmp` only
|
||||
- Never `/`, `/etc`, `/var/log`, host paths, or sibling pod volumes
|
||||
|
||||
Persistent state MUST live in PostgreSQL (`postgres.d-ma.be`) or object storage —
|
||||
not the pod filesystem.
|
||||
|
||||
## Approved operations
|
||||
|
||||
The agent MAY:
|
||||
|
||||
- Issue LLM inference requests via the LiteLLM adapter
|
||||
- Query the brain MCP for prior art / postmortems
|
||||
- Read repos, issues, and PRs via gitea-mcp
|
||||
- Emit OTLP spans for `invoke_agent`, `generate_content`, and any custom tool spans
|
||||
- Write to its own k8s logs (stdout/stderr; structured `slog`)
|
||||
|
||||
The agent MUST NOT, without explicit opt-in in code review:
|
||||
|
||||
- Write to gitea (open PR, push branch, comment) — requires `--write` flag and audit log
|
||||
- Execute shell commands in non-sandboxed contexts
|
||||
- Mutate k8s resources (no kubectl/Helm/Flux operations from runtime)
|
||||
- Forward LLM outputs to external systems before redaction
|
||||
- Hold conversation logs longer than the session TTL (24h default)
|
||||
|
||||
## Failure posture
|
||||
|
||||
- Outbound DNS failure → fail closed, retry with jitter, surface to caller
|
||||
- LLM 5xx → bounded retry (3 attempts), then return error to caller
|
||||
- Brain MCP unavailable → continue without prior-art lookup; log degraded mode
|
||||
- OTLP unavailable → drop spans silently; never block request path
|
||||
|
||||
## Review triggers
|
||||
|
||||
Bump this doc + `agent-policy.yaml` when:
|
||||
|
||||
- A new external endpoint is added
|
||||
- A new tool with side effects is wired in
|
||||
- The agent gains write access to anything (repo, DB table, filesystem path)
|
||||
- A client engagement narrows or widens the data scope
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
FROM golang:1.26-alpine AS build
|
||||
WORKDIR /src
|
||||
RUN apk add --no-cache git
|
||||
COPY go.mod ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
RUN CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o /out/app ./cmd/__PROJECT_NAME__
|
||||
|
||||
FROM gcr.io/distroless/static-debian12:nonroot
|
||||
COPY --from=build /out/app /app
|
||||
USER nonroot:nonroot
|
||||
ENTRYPOINT ["/app"]
|
||||
@@ -0,0 +1,101 @@
|
||||
# NetworkPolicy for __PROJECT_NAME__.
|
||||
#
|
||||
# Pairs with AGENT_BOUNDARIES.md. Egress is allow-listed: LiteLLM, brain-mcp,
|
||||
# gitea-mcp, OTLP collector, in-cluster DNS. Everything else is denied.
|
||||
#
|
||||
# Apply in the __PROJECT_NAME__ namespace. Substitute __PROJECT_NAME__ at
|
||||
# render time (envsubst, kustomize replacement, or sed in CI).
|
||||
---
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: __PROJECT_NAME__-egress
|
||||
namespace: __PROJECT_NAME__
|
||||
labels:
|
||||
app.kubernetes.io/name: __PROJECT_NAME__
|
||||
app.kubernetes.io/component: agent
|
||||
spec:
|
||||
podSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: __PROJECT_NAME__
|
||||
policyTypes:
|
||||
- Egress
|
||||
egress:
|
||||
# In-cluster DNS — required for resolving any of the endpoints below.
|
||||
- to:
|
||||
- namespaceSelector:
|
||||
matchLabels:
|
||||
kubernetes.io/metadata.name: kube-system
|
||||
podSelector:
|
||||
matchLabels:
|
||||
k8s-app: kube-dns
|
||||
ports:
|
||||
- protocol: UDP
|
||||
port: 53
|
||||
- protocol: TCP
|
||||
port: 53
|
||||
|
||||
# LiteLLM — model inference proxy.
|
||||
- to:
|
||||
- namespaceSelector:
|
||||
matchLabels:
|
||||
kubernetes.io/metadata.name: litellm
|
||||
podSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: litellm
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: 4000
|
||||
|
||||
# Brain MCP — knowledge base query/write.
|
||||
- to:
|
||||
- namespaceSelector:
|
||||
matchLabels:
|
||||
kubernetes.io/metadata.name: ingestion
|
||||
podSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: brain-mcp
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: 8080
|
||||
|
||||
# Gitea MCP — repo/issue/PR access.
|
||||
- to:
|
||||
- namespaceSelector:
|
||||
matchLabels:
|
||||
kubernetes.io/metadata.name: gitea
|
||||
podSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: gitea-mcp
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: 8080
|
||||
|
||||
# OTLP — trace export to Jaeger collector.
|
||||
- to:
|
||||
- namespaceSelector:
|
||||
matchLabels:
|
||||
kubernetes.io/metadata.name: observability
|
||||
podSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: jaeger
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: 4318
|
||||
---
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: __PROJECT_NAME__-default-deny
|
||||
namespace: __PROJECT_NAME__
|
||||
labels:
|
||||
app.kubernetes.io/name: __PROJECT_NAME__
|
||||
spec:
|
||||
podSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: __PROJECT_NAME__
|
||||
policyTypes:
|
||||
- Ingress
|
||||
- Egress
|
||||
# Empty rules = deny-all. The allow-list above is additive on Egress.
|
||||
# Ingress stays denied unless a sibling policy opens specific ports.
|
||||
@@ -0,0 +1,74 @@
|
||||
// Package agent wraps the ADK runner with the project's defaults.
|
||||
//
|
||||
// Keep this thin: construction, defaults, and the Run loop. Tool wiring,
|
||||
// callbacks, and any business logic belong in sibling packages.
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"iter"
|
||||
|
||||
"google.golang.org/adk/agent"
|
||||
"google.golang.org/adk/agent/llmagent"
|
||||
"google.golang.org/adk/model"
|
||||
"google.golang.org/adk/runner"
|
||||
"google.golang.org/adk/session"
|
||||
"google.golang.org/genai"
|
||||
)
|
||||
|
||||
// Config holds the minimum needed to spin up an agent + runner.
|
||||
type Config struct {
|
||||
Name string
|
||||
Description string
|
||||
Instruction string
|
||||
Model model.LLM
|
||||
}
|
||||
|
||||
// Agent is the runnable unit. Construct with New, drive with Run.
|
||||
type Agent struct {
|
||||
name string
|
||||
runner *runner.Runner
|
||||
}
|
||||
|
||||
// New builds an llmagent and an in-memory-session runner around it.
|
||||
func New(cfg Config) (*Agent, error) {
|
||||
if cfg.Name == "" {
|
||||
return nil, fmt.Errorf("agent: Name required")
|
||||
}
|
||||
if cfg.Model == nil {
|
||||
return nil, fmt.Errorf("agent: Model required")
|
||||
}
|
||||
|
||||
ag, err := llmagent.New(llmagent.Config{
|
||||
Name: cfg.Name,
|
||||
Description: cfg.Description,
|
||||
Model: cfg.Model,
|
||||
Instruction: cfg.Instruction,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("agent: build llmagent: %w", err)
|
||||
}
|
||||
|
||||
r, err := runner.New(runner.Config{
|
||||
AppName: cfg.Name,
|
||||
Agent: ag,
|
||||
SessionService: session.InMemoryService(),
|
||||
AutoCreateSession: true,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("agent: build runner: %w", err)
|
||||
}
|
||||
|
||||
return &Agent{name: cfg.Name, runner: r}, nil
|
||||
}
|
||||
|
||||
// Run dispatches a single user turn and returns the event iterator.
|
||||
// The caller is responsible for draining it and handling errors.
|
||||
func (a *Agent) Run(ctx context.Context, userID, sessionID, text string) iter.Seq2[*session.Event, error] {
|
||||
msg := genai.NewContentFromText(text, "user")
|
||||
return a.runner.Run(ctx, userID, sessionID, msg, agent.RunConfig{})
|
||||
}
|
||||
|
||||
// Name returns the registered agent name (also used as ADK AppName).
|
||||
func (a *Agent) Name() string { return a.name }
|
||||
Reference in New Issue
Block a user