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