diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..56c5555 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +.git +.github +.githooks +dist +build +env.secrets +config/agent.yml +*.md +!README.md +scripts diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100644 index 0000000..7042d0f --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,22 @@ +#!/usr/bin/env sh +# Same check as fluid-pub/actions go-workload-ci "Verify formatting". +set -e + +repo_root="$(git rev-parse --show-toplevel 2>/dev/null)" || { + echo "pre-commit: not inside a Git work tree" >&2 + exit 1 +} + +cd "$repo_root" || exit 1 + +if ! command -v gofmt >/dev/null 2>&1; then + echo "pre-commit: gofmt not found in PATH" >&2 + exit 1 +fi + +unformatted="$(gofmt -l .)" +if [ -n "$unformatted" ]; then + echo "pre-commit: gofmt would reformat these files (run: gofmt -w .):" >&2 + echo "$unformatted" >&2 + exit 1 +fi diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..93bb019 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,57 @@ +# Dependabot version updates for fluid-pub/agent-gitlab. +version: 2 +updates: + - package-ecosystem: gomod + directory: "/" + schedule: + interval: weekly + day: monday + target-branch: develop + assignees: + - "fuse" + reviewers: + - "fuse" + open-pull-requests-limit: 5 + commit-message: + prefix: chore(deps) + labels: + - dependencies + - go + + - package-ecosystem: docker + directory: "/" + schedule: + interval: weekly + day: monday + target-branch: develop + assignees: + - "fuse" + reviewers: + - "fuse" + open-pull-requests-limit: 5 + commit-message: + prefix: chore(deps) + labels: + - dependencies + - docker + + - package-ecosystem: github-actions + directory: "/" + schedule: + interval: weekly + day: monday + target-branch: develop + assignees: + - "fuse" + reviewers: + - "fuse" + open-pull-requests-limit: 5 + commit-message: + prefix: chore(deps) + labels: + - dependencies + - github-actions + groups: + github-actions: + patterns: + - "*" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..af917d8 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,19 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + - develop + +permissions: + contents: read + +jobs: + ci: + uses: fluid-pub/actions/.github/workflows/go-workload-ci.yml@v1 + with: + workload_kind: agent + core_repository: fluid-pub/agent-core + core_ref: develop diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..62d5c0f --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,54 @@ +# CodeQL analysis for Go (aligned with go-workload-ci core checkout layout). +name: CodeQL + +on: + push: + branches: + - develop + - main + pull_request: + branches: + - develop + - main + schedule: + - cron: "27 5 * * 1" + +permissions: + actions: read + contents: read + security-events: write + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Checkout shared core + uses: actions/checkout@v6 + with: + repository: fluid-pub/agent-core + ref: develop + path: core + + - name: Align go.mod replace for CI layout + run: | + go mod edit -replace fluid/agents/core=./core + go mod download + + - uses: actions/setup-go@v6 + with: + go-version: "1.23" + cache-dependency-path: go.sum + + - name: Initialize CodeQL + uses: github/codeql-action/init@v4 + with: + languages: go + + - name: Autobuild + uses: github/codeql-action/autobuild@v4 + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v4 diff --git a/.github/workflows/release-on-semver-tag.yml b/.github/workflows/release-on-semver-tag.yml new file mode 100644 index 0000000..b2280c9 --- /dev/null +++ b/.github/workflows/release-on-semver-tag.yml @@ -0,0 +1,21 @@ +name: Release on semver tag + +on: + push: + tags: + - "[0-9]+.[0-9]+.[0-9]+" + - "[0-9]+.[0-9]+.[0-9]+-*" + +permissions: + contents: write + packages: write + +jobs: + release: + uses: fluid-pub/actions/.github/workflows/go-workload-release.yml@v1 + with: + workload_kind: agent + binary_name: fluid-agent-gitlab + core_repository: fluid-pub/agent-core + # Pin agent-core until matching semver tags exist for both repos (see agent-core 0.1.1). + core_ref: "0.1.1" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f4720fe --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +/build/ +/dist/ +*.exe +env.secrets +config/agent.yml diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..0ead9d6 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,6 @@ +# Required when this module is the root of fluid-pub/agent-gitlab on GitHub. +# Monorepo dev may use replace => ../core in go.mod without initializing this submodule. +[submodule "core"] + path = core + url = https://github.com/fluid-pub/agent-core.git + branch = develop diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..a9bfa72 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,20 @@ +# Changelog — agent-gitlab + +All notable changes to **fluid-pub/agent-gitlab** are documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +Tag naming: `0.y.z` (no `v` prefix). Align `cmd/version.go` and `config/agent.example.yml` (`agent.version`) with the tag before release. + +## [Unreleased] + +## [0.1.0] - 2026-05-26 + +### Added + +- Initial public release on **agent-core** (WebSocket execution, enrollment, `runtime_config` sync). +- Skills: GitLab API (`create_branch`, `commit_files`, `create_mr`, MR approve/merge/status/assert_merged, `agent.health`) and **`gitlab.repo.checkout_mr`** (local git + optional `run_as` / `chown_repo_to`). +- **`service_credentials`**: `local` (default) or `control_plane` (GitLab token prefetch via control plane after WSS connect). +- CI/CD via `fluid-pub/actions`, container image with **git** for checkout skills, GitHub Release asset `fluid-agent-gitlab-linux-amd64`. +- Local `gofmt` pre-commit hook matching CI. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..db13e50 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,36 @@ +# syntax=docker/dockerfile:1 +# Fluid GitLab execution agent — git and chown required for gitlab.repo.checkout_mr. + +FROM golang:1.26-bookworm AS build + +ARG BINARY_NAME=fluid-agent-gitlab +ARG VERSION=0.0.0 +ARG TARGETOS=linux +ARG TARGETARCH=amd64 + +WORKDIR /src + +COPY go.mod go.sum ./ +COPY core ./core +COPY cmd ./cmd +COPY internal ./internal + +RUN go mod download + +RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \ + go build -ldflags "-s -w -X main.Version=${VERSION}" \ + -o /out/workload ./cmd + +FROM debian:bookworm-slim + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates git \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=build /out/workload /usr/local/bin/workload + +# checkout_mr may use chown; typical deployment runs this agent with host workspace mounts. +USER root + +ENTRYPOINT ["/usr/local/bin/workload"] +CMD ["-config", "/etc/fluid/config/config.yaml"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..5476be7 --- /dev/null +++ b/Makefile @@ -0,0 +1,40 @@ +GO ?= go +CONFIG ?= config/agent.yml +BINARY ?= dist/fluid-agent-gitlab +VERSION ?= 0.1.0 +LDFLAGS := -ldflags "-s -w -X main.Version=$(VERSION)" + +.PHONY: deps dev build build-linux test fmt lint help monorepo-replace + +monorepo-replace: + $(GO) mod edit -replace fluid/agents/core=../core + @$(GO) mod tidy + +deps: + @test -d core || test -d ../core || (echo "Run: git submodule update --init --recursive (public repo) or develop from fluid monorepo with ../core" && exit 1) + $(GO) mod download + @$(GO) mod tidy + +dev: deps + @test -f env.secrets || (echo "Missing env.secrets. Create it from env.secrets.example." && exit 1) + @set -a; . ./env.secrets; set +a; $(GO) run ./cmd -config $(CONFIG) + +build: deps + $(GO) build ./... + +build-linux: deps + @mkdir -p dist + GOOS=linux GOARCH=amd64 CGO_ENABLED=0 $(GO) build $(LDFLAGS) -o $(BINARY) ./cmd + +test: deps + $(GO) test ./... + +fmt: + gofmt -w . + $(GO) fmt ./... + +lint: + @echo "No linter configured for this module yet." + +help: + @echo "Targets: deps dev build build-linux test fmt monorepo-replace" diff --git a/README.md b/README.md index 0466b31..9e1531b 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,83 @@ -# agent-gitlab -Fluid GitLab agent +# fluid-pub/agent-gitlab + +Fluid **execution agent** for **GitLab**: maintains a **persistent WSS** connection to the control plane (`/v1/agents/websocket`) for skills, logs, and `runtime_config` sync. GitLab REST API skills and **`gitlab.repo.checkout_mr`** (local **git**) run in this agent — not on the Linux interface agent. + +## Repository layout + +| Path | Role | +|------|------| +| `core/` | Git submodule → [`fluid-pub/agent-core`](https://github.com/fluid-pub/agent-core) | +| `cmd/` | Entrypoint and `cmd/version.go` (semver for releases) | +| `internal/` | GitLab API client, agent skills, config | +| `config/agent.example.yml` | Configuration template | +| `.github/workflows/` | CI and release via [`fluid-pub/actions`](https://github.com/fluid-pub/actions) | + +## Local development + +One-time per clone, enable the same **`gofmt`** check as CI: + +```bash +./scripts/install-git-hooks.sh +``` + +```bash +git submodule update --init --recursive +cp config/agent.example.yml config/agent.yml +cp env.secrets.example env.secrets +# Set GITLAB_* and FLUID_CONTROLPLANE_* in env.secrets (never commit that file). +source env.secrets +make dev +``` + +`make dev` runs `go run ./cmd` with `-config config/agent.yml`. + +**Monorepo Fluid** (`code/agents/gitlab/`): use `make monorepo-replace` so `go.mod` points at `../core` instead of the `core/` submodule. Do not commit that replace on `develop` (CI and releases use `replace => ./core`). + +### Git in the Fluid workspace + +```bash +cd code/agents/gitlab +git remote add origin git@github.com:fluid-pub/agent-gitlab.git # if needed +git fetch origin +git checkout -B develop origin/develop +git submodule update --init --recursive +./scripts/install-git-hooks.sh +make monorepo-replace # optional when using code/agents/core in the monorepo +``` + +Keep `env.secrets` and `config/agent.yml` local (gitignored). + +## Control plane connection + +| Phase | Transport | Purpose | +|-------|-----------|---------| +| Steady state | **WSS** (`controlplane.websocket_url`) | `skill_invoke` / `skill_result`, log events, `runtime_config` push | +| First boot only (optional) | HTTP `POST /api/v1/enrollment/enroll` | Exchange `FLUID_ENROLLMENT_TOKEN` for `organization_uuid` + connection token | + +Durable credentials: `/etc/fluid/gitlab/credentials.yaml` (`-credentials` flag). Enroll with `agent_type: gitlab`, `principal: execution_agent`. + +### Service credentials + +- **`local`** (default): GitLab token from config / env / `FLUID_SERVICE_CREDENTIALS_ENV_FILE`. +- **`control_plane`**: after WSS connect, prefetch GitLab token from the control plane (requires `use_case_run_id` in enrollment extra args or `FLUID_USE_CASE_RUN_ID`). + +## Container image + +The published image includes **`git`** (and runs as **root**) so **`gitlab.repo.checkout_mr`** can clone under `/tmp/fluid/` workspaces. API-only deployments may still use the same image. + +## Releases + +Push a semver tag **without** `v` (e.g. `0.1.0`) matching `var Version` in `cmd/version.go`. The release workflow publishes: + +- `ghcr.io/fluid-pub/agent-gitlab:` +- GitHub Release asset `fluid-agent-gitlab-linux-amd64` and `SHA256SUMS.txt` + +Release builds pin **`agent-core`** to the **same semver tag** when `core_ref` is empty in the workflow (see `.github/workflows/release-on-semver-tag.yml`). + +## Changelog + +Release notes: [CHANGELOG.md](CHANGELOG.md). + +## Security + +See [SECURITY.md](SECURITY.md) for vulnerability reporting. Repository automation includes Dependabot and CodeQL. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..9bcbc62 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,27 @@ +# Security policy + +## Supported versions + +Security fixes are applied on the latest release line published from this repository (semver tags on `develop` / GitHub Releases). Older tags are not maintained unless stated in a release advisory. + +## Reporting a vulnerability + +**Do not** open a public GitHub issue for security vulnerabilities. + +Preferred channels: + +1. **Private vulnerability reporting** (if enabled on this repository): use **Security → Advisories → Report a vulnerability** on GitHub. +2. **GitHub Security Advisories** for this repository: [fluid-pub/agent-gitlab security advisories](https://github.com/fluid-pub/agent-gitlab/security/advisories). +3. If neither channel is available, contact the Fluid maintainers through your usual Fluid support or security contact path. + +Include enough detail to reproduce the issue (affected version, configuration, steps, impact). We aim to acknowledge reports within a few business days and will coordinate disclosure once a fix is available. + +## What to expect + +- Confirmed issues are tracked as security advisories or private reports until a fix is released. +- Credit is given to reporters when they agree, unless anonymity is requested. +- Dependabot and CodeQL may open pull requests for dependency or static-analysis findings; those are handled like other contributions via `develop`. + +## Scope + +This policy covers the **agent-gitlab** source code, container image, and release artifacts built from this repository. It does not cover third-party services (GitLab, your control plane deployment, or credentials you configure locally). diff --git a/cmd/main.go b/cmd/main.go new file mode 100644 index 0000000..d7a3096 --- /dev/null +++ b/cmd/main.go @@ -0,0 +1,152 @@ +package main + +import ( + "context" + "flag" + "fmt" + "log" + "os" + "os/signal" + "strings" + "syscall" + "time" + + "fluid/agents/core/enroll" + coreversion "fluid/agents/core/version" + "fluid/agents/gitlab/internal/agent" + "fluid/agents/gitlab/internal/config" +) + +const ( + envEnrollmentToken = "FLUID_ENROLLMENT_TOKEN" + envControlplaneHTTPBase = "FLUID_CONTROLPLANE_HTTP_BASE" + envControlplaneWebSocket = "FLUID_CONTROLPLANE_WEBSOCKET_URL" +) + +func enrollmentTokenForLog(secret string) string { + s := strings.TrimSpace(secret) + if s == "" { + return "empty" + } + return fmt.Sprintf("present (length=%d)", len(s)) +} + +func main() { + coreversion.ExitIfVersionOnly(Version) + + configPath := flag.String("config", "config/agent.yml", "Path to agent YAML configuration") + credentialsPath := flag.String("credentials", "/etc/fluid/gitlab/credentials.yaml", "Durable credentials (organization_uuid + connection token)") + enrollmentEnvPath := flag.String("enrollment-env", "/etc/fluid/gitlab/enrollment.env", "systemd EnvironmentFile path removed after enrollment when FLUID_ENROLL_PURGE_ENROLLMENT_SOURCES is true") + flag.Parse() + + cfg, err := config.LoadConfig(*configPath) + if err != nil { + log.Fatalf("failed to load config: %v", err) + } + + if p := strings.TrimSpace(os.Getenv("FLUID_SERVICE_CREDENTIALS_ENV_FILE")); p != "" { + if err := config.MergeDotenvIntoGitLab(cfg, p); err != nil { + log.Fatalf("service credentials env file: %v", err) + } + log.Printf("merged GitLab token from FLUID_SERVICE_CREDENTIALS_ENV_FILE=%q", p) + } + + if err := config.MergeCredentialsFromFile(cfg, *credentialsPath); err != nil { + log.Fatalf("failed to merge credentials: %v", err) + } + + if ws := strings.TrimSpace(os.Getenv(envControlplaneWebSocket)); ws != "" { + cfg.Controlplane.WebSocketURL = ws + } + + enrollSecret := strings.TrimSpace(os.Getenv(envEnrollmentToken)) + httpBase := strings.TrimSpace(os.Getenv(envControlplaneHTTPBase)) + + if strings.TrimSpace(cfg.Controlplane.Token) == "" && enrollSecret != "" { + if httpBase == "" { + log.Fatalf("enrollment: %s must be set for POST /api/v1/enrollment/enroll (e.g. via %s)", + envControlplaneHTTPBase, *enrollmentEnvPath) + } + extra, err := enroll.ExtraArgsFromEnv() + if err != nil { + log.Fatalf("enrollment: %v", err) + } + hostname, _ := os.Hostname() + override := strings.TrimSpace(os.Getenv(enroll.EnvEnrollmentName)) + if override == "" { + override = strings.TrimSpace(os.Getenv(enroll.EnvEnrollmentNameDeprecated)) + } + if override == "" { + override = strings.TrimSpace(cfg.Enrollment.Name) + } + if override == "" { + override = strings.TrimSpace(cfg.Enrollment.DeprecatedDisplayName) + } + enrollName := enroll.ResolveExecutionAgentEnrollmentName(hostname, "gitlab", override) + log.Printf("enrollment: starting exchange hostname=%q name=%q base_url=%s websocket_url=%s enrollment_token=%s", + hostname, + enrollName, + httpBase, + strings.TrimSpace(cfg.Controlplane.WebSocketURL), + enrollmentTokenForLog(enrollSecret), + ) + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + res, err := enroll.Exchange(ctx, enroll.Params{ + BaseURL: httpBase, + EnrollmentToken: enrollSecret, + Hostname: hostname, + Name: enrollName, + AgentType: "gitlab", + ExtraArgs: extra, + }) + if err != nil { + log.Fatalf("enrollment: exchange failed: %v", err) + } + cfg.Controlplane.OrganizationUUID = res.OrganizationUUID + cfg.Controlplane.Token = res.ConnectionToken + log.Printf("enrollment: control plane accepted request resource_kind=%q resource_id=%q organization_uuid=%q status=%q use_count=%d", + strings.TrimSpace(res.ResourceKind), + strings.TrimSpace(res.ResourceID), + strings.TrimSpace(res.OrganizationUUID), + strings.TrimSpace(res.Status), + res.UseCount, + ) + if err := config.WriteCredentialsFile(*credentialsPath, cfg.Controlplane); err != nil { + log.Fatalf("enrollment: persist credentials to %q failed: %v", *credentialsPath, err) + } + log.Printf("enrollment: wrote credentials to %q", *credentialsPath) + if enroll.PurgeEnrollmentSources() { + p := strings.TrimSpace(*enrollmentEnvPath) + if p != "" { + if err := os.Remove(p); err != nil && !os.IsNotExist(err) { + log.Fatalf("enrollment: remove %q failed: %v", p, err) + } + log.Printf("enrollment: removed %q; enrollment complete", p) + } + } else { + log.Printf("enrollment: kept enrollment sources (FLUID_ENROLL_PURGE_ENROLLMENT_SOURCES=false)") + } + } + + if err := cfg.Validate(); err != nil { + log.Fatalf("invalid configuration: %v", err) + } + + execAgent, err := agent.New(cfg) + if err != nil { + log.Fatalf("failed to initialize execution agent: %v", err) + } + + if err := execAgent.Start(); err != nil { + log.Fatalf("failed to start execution agent: %v", err) + } + log.Printf("agent process is running, waiting for signals") + + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + <-sigCh + + log.Printf("shutdown signal received") + execAgent.Stop() +} diff --git a/cmd/version.go b/cmd/version.go new file mode 100644 index 0000000..3ba27b7 --- /dev/null +++ b/cmd/version.go @@ -0,0 +1,3 @@ +package main + +var Version = "0.1.0" diff --git a/config/agent.example.yml b/config/agent.example.yml new file mode 100644 index 0000000..d1378d7 --- /dev/null +++ b/config/agent.example.yml @@ -0,0 +1,112 @@ +# GitLab execution agent: API skills and local git checkout for MR context (see README). +# service_credentials: local (default) | control_plane +service_credentials: local + +agent: + name: "gitlab-exec" + version: "0.1.0" + mode: "execution" + +gitlab: + url: "${GITLAB_URL}" + token: "${GITLAB_TOKEN}" + api_version: "v4" + project_path: "${GITLAB_PROJECT_PATH}" + target_branch: "main" + +controlplane: + websocket_url: "${FLUID_CONTROLPLANE_WEBSOCKET_URL}" + organization_uuid: "${FLUID_CONTROLPLANE_ORGANIZATION_UUID}" + token: "${FLUID_CONTROLPLANE_TOKEN}" + +logs: + enabled: true + verbosity: "normal" + +skills: + allowed: + - gitlab.create_branch + - gitlab.commit_files + - gitlab.create_mr + - gitlab.agent.health + - gitlab.merge_request.approval_status + - gitlab.merge_request.status + - gitlab.merge_request.approve + - gitlab.merge_request.merge + - gitlab.merge_request.assert_merged + - gitlab.repo.checkout_mr + definitions: + gitlab.create_branch: + result: + type: object + properties: + branch: { type: string } + gitlab.commit_files: + result: + type: object + gitlab.create_mr: + result: + type: object + properties: + mr_url: { type: string } + gitlab.agent.health: + result: + type: object + properties: + connected: { type: boolean } + gitlab.merge_request.approval_status: + result: + type: object + properties: + merge_request_iid: { type: integer } + approved_by_current_user: { type: boolean } + current_user_id: { type: integer } + current_username: { type: string } + gitlab.merge_request.status: + result: + type: object + properties: + exists: { type: boolean } + merge_request_iid: { type: integer } + iid: { type: integer } + web_url: { type: string } + state: { type: string } + detailed_merge_status: { type: string } + merge_error: { type: string } + gitlab.merge_request.approve: + result: + type: object + properties: + iid: { type: integer } + web_url: { type: string } + state: { type: string } + gitlab.merge_request.merge: + result: + type: object + properties: + iid: { type: integer } + web_url: { type: string } + state: { type: string } + gitlab.merge_request.assert_merged: + result: + type: object + properties: + iid: { type: integer } + web_url: { type: string } + state: { type: string } + gitlab.repo.checkout_mr: + with: + type: object + properties: + run_as: + type: string + description: Optional POSIX login. When the agent runs as root, git uses that uid/gid. Ignored for chown when set. + chown_repo_to: + type: string + description: Optional POSIX login. After checkout (if run_as unset), chown -R under /tmp/fluid/ workspace paths. + result: + type: object + properties: + target_path: { type: string } + merge_request_iid: { type: integer } + ref: { type: string } diff --git a/core b/core new file mode 160000 index 0000000..1cfd005 --- /dev/null +++ b/core @@ -0,0 +1 @@ +Subproject commit 1cfd00523eddd23c52b0973b53db44284bb3070e diff --git a/env.secrets.example b/env.secrets.example new file mode 100644 index 0000000..8e19d88 --- /dev/null +++ b/env.secrets.example @@ -0,0 +1,18 @@ +# Copy to env.secrets and source before `make dev` (see Makefile). +# +# Control plane (execution agent): persistent WebSocket (WSS), not HTTP like probes. + +FLUID_CONTROLPLANE_WEBSOCKET_URL=wss://dev.fluid.pub/v1/agents/websocket +FLUID_CONTROLPLANE_ORGANIZATION_UUID=replace-with-organization-uuid +FLUID_CONTROLPLANE_TOKEN=replace-with-connection-token + +# Optional first-boot enrollment only (one-shot POST /api/v1/enrollment/enroll, then WSS above): +# FLUID_ENROLLMENT_TOKEN=replace-with-enrollment-token +# FLUID_CONTROLPLANE_HTTP_BASE=https://dev.fluid.pub + +GITLAB_URL=https://gitlab.example.com +GITLAB_TOKEN=replace-with-gitlab-token +GITLAB_PROJECT_PATH=group/project + +# Optional: control_plane service credentials (requires FLUID_USE_CASE_RUN_ID or enrollment extra args) +# FLUID_SERVICE_CREDENTIALS=control_plane diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..01d0be8 --- /dev/null +++ b/go.mod @@ -0,0 +1,12 @@ +module fluid/agents/gitlab + +go 1.23 + +require ( + fluid/agents/core v0.0.0 + gopkg.in/yaml.v3 v3.0.1 +) + +require github.com/gorilla/websocket v1.5.3 // indirect + +replace fluid/agents/core => ./core diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..6a2c68d --- /dev/null +++ b/go.sum @@ -0,0 +1,6 @@ +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/agent/agent.go b/internal/agent/agent.go new file mode 100644 index 0000000..ffdba53 --- /dev/null +++ b/internal/agent/agent.go @@ -0,0 +1,359 @@ +package agent + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/url" + "os" + "strings" + "time" + + "fluid/agents/core/cpcredentials" + "fluid/agents/core/enroll" + coreexec "fluid/agents/core/execution" + "fluid/agents/core/skillresult" + "fluid/agents/gitlab/internal/config" + "fluid/agents/gitlab/internal/gitlab" +) + +type Agent struct { + cfg *config.Config + core *coreexec.Agent + gitlab *gitlab.Client + allowed map[string]struct{} +} + +func New(cfg *config.Config) (*Agent, error) { + allowed := make(map[string]struct{}, len(cfg.Skills.Allowed)) + for _, s := range cfg.Skills.Allowed { + allowed[strings.TrimSpace(s)] = struct{}{} + } + + return &Agent{ + cfg: cfg, + gitlab: gitlab.New(cfg.GitLab.URL, cfg.GitLab.Token, cfg.GitLab.APIv), + allowed: allowed, + }, nil +} + +func (a *Agent) Start() error { + coreCfg := coreexec.Config{ + WebSocketURL: a.cfg.Controlplane.WebSocketURL, + OrganizationUUID: a.cfg.Controlplane.OrganizationUUID, + Token: a.cfg.Controlplane.Token, + Name: "gitlab", + AllowedSkills: len(a.allowed), + LogEventsEnabled: a.cfg.Logs.Enabled == nil || *a.cfg.Logs.Enabled, + LogVerbosity: a.cfg.Logs.Verbosity, + RuntimeConfig: runtimeConfigForControlPlane(a.cfg), + } + if a.cfg.ServiceCredentialsMode() == config.ServiceCredentialsControlPlane { + coreCfg.AfterConnect = a.prefetchGitLabToken + } + a.core = coreexec.New(coreCfg, a.execute) + return a.core.Start() +} + +func (a *Agent) prefetchGitLabToken() error { + rid := enroll.RunIDFromPrefetchSources() + if strings.TrimSpace(rid) == "" { + return fmt.Errorf("service_credentials=control_plane requires use_case_run_id (FLUID_USE_CASE_RUN_ID or FLUID_ENROLLMENT_EXTRA_ARGS JSON)") + } + skill := strings.TrimSpace(os.Getenv("FLUID_CP_CREDENTIAL_PREFETCH_SKILL_ID")) + if skill == "" { + skill = "gitlab.agent.health" + } + if _, ok := a.allowed[skill]; !ok { + return fmt.Errorf("prefetch skill %q must appear in skills.allowed", skill) + } + origin, err := cpcredentials.HTTPOriginFromWebSocketURL(a.cfg.Controlplane.WebSocketURL) + if err != nil { + return fmt.Errorf("derive http origin: %w", err) + } + ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) + defer cancel() + issued, err := cpcredentials.IssueGitLab(ctx, cpcredentials.IssueGitLabParams{ + HTTPOrigin: origin, + OrganizationUUID: a.cfg.Controlplane.OrganizationUUID, + ConnectionToken: a.cfg.Controlplane.Token, + SkillID: skill, + RunID: rid, + }) + if err != nil { + return err + } + a.gitlab.SetToken(issued.Token) + return nil +} + +func runtimeConfigForControlPlane(cfg *config.Config) map[string]interface{} { + skills := map[string]interface{}{ + "allowed": cfg.Skills.Allowed, + } + if len(cfg.Skills.Definitions) > 0 { + skills["definitions"] = cfg.Skills.Definitions + } + return map[string]interface{}{ + "agent": map[string]interface{}{ + "mode": cfg.Agent.Mode, + "name": cfg.Agent.Name, + "version": cfg.Agent.Version, + }, + "skills": skills, + } +} + +func (a *Agent) Stop() { + if a.core != nil { + a.core.Stop() + } +} + +func (a *Agent) execute(skill string, payload map[string]interface{}, _ map[string]interface{}) (map[string]interface{}, error) { + if _, ok := a.allowed[skill]; !ok { + return nil, fmt.Errorf("skill not allowed: %s", skill) + } + + switch skill { + case "gitlab.create_branch": + branch, _ := payload["branch"].(string) + ref, _ := payload["ref"].(string) + if ref == "" { + ref = a.cfg.GitLab.TargetBranch + } + if err := a.gitlab.CreateBranch(a.cfg.GitLab.ProjectPath, branch, ref); err != nil { + return nil, err + } + return skillresult.Success(map[string]interface{}{"branch": branch}), nil + + case "gitlab.commit_files": + branch, _ := payload["branch"].(string) + message, _ := payload["message"].(string) + actions, _ := payload["actions"].([]interface{}) + normalized := make([]map[string]interface{}, 0, len(actions)) + for _, a := range actions { + if m, ok := a.(map[string]interface{}); ok { + normalized = append(normalized, m) + } + } + if err := a.gitlab.CommitFiles(a.cfg.GitLab.ProjectPath, branch, message, normalized); err != nil { + return nil, err + } + return skillresult.Success(map[string]interface{}{}), nil + + case "gitlab.create_mr": + source, _ := payload["source_branch"].(string) + target, _ := payload["target_branch"].(string) + if target == "" { + target = a.cfg.GitLab.TargetBranch + } + title, _ := payload["title"].(string) + description, _ := payload["description"].(string) + url, err := a.gitlab.CreateMergeRequest(a.cfg.GitLab.ProjectPath, source, target, title, description) + if err != nil { + return nil, err + } + return skillresult.Success(map[string]interface{}{"mr_url": url}), nil + + case "gitlab.agent.health": + return skillresult.Success(map[string]interface{}{"connected": true}), nil + + case "gitlab.merge_request.approve": + projectPath := stringFromPayload(payload, "project_path", a.cfg.GitLab.ProjectPath) + iid, err := intFromPayload(payload["merge_request_iid"]) + if err != nil { + return nil, err + } + res, err := a.gitlab.ApproveMergeRequest(projectPath, iid) + if err != nil { + return nil, err + } + return skillresult.Success(map[string]interface{}{ + "iid": res.IID, + "web_url": res.WebURL, + "state": res.State, + }), nil + + case "gitlab.merge_request.approval_status": + projectPath := stringFromPayload(payload, "project_path", a.cfg.GitLab.ProjectPath) + iid, err := intFromPayload(payload["merge_request_iid"]) + if err != nil { + return nil, err + } + approved, user, err := a.gitlab.MergeRequestApprovedByCurrentUser(projectPath, iid) + if err != nil { + return nil, err + } + out := map[string]interface{}{ + "merge_request_iid": iid, + "approved_by_current_user": approved, + } + if user != nil { + out["current_user_id"] = user.ID + out["current_username"] = user.Username + } + return skillresult.Success(out), nil + + case "gitlab.merge_request.status": + projectPath := stringFromPayload(payload, "project_path", a.cfg.GitLab.ProjectPath) + iid, err := intFromPayload(payload["merge_request_iid"]) + if err != nil { + return nil, err + } + mr, err := a.gitlab.GetMergeRequest(projectPath, iid) + if err != nil { + var apiErr *gitlab.APIError + if errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusNotFound { + return skillresult.Success(map[string]interface{}{ + "exists": false, + "merge_request_iid": iid, + "state": "not_found", + }), nil + } + return nil, err + } + return skillresult.Success(map[string]interface{}{ + "exists": true, + "merge_request_iid": mr.IID, + "iid": mr.IID, + "web_url": mr.WebURL, + "state": mr.State, + "detailed_merge_status": mr.DetailedMergeStatus, + "merge_error": strings.TrimSpace(mr.MergeError), + }), nil + + case "gitlab.merge_request.merge": + projectPath := stringFromPayload(payload, "project_path", a.cfg.GitLab.ProjectPath) + iid, err := intFromPayload(payload["merge_request_iid"]) + if err != nil { + return nil, err + } + msg, _ := payload["merge_commit_message"].(string) + res, err := a.gitlab.MergeMergeRequest(projectPath, iid, msg) + if err != nil { + return nil, err + } + return skillresult.Success(map[string]interface{}{ + "iid": res.IID, + "web_url": res.WebURL, + "state": res.State, + }), nil + + case "gitlab.merge_request.assert_merged": + projectPath := stringFromPayload(payload, "project_path", a.cfg.GitLab.ProjectPath) + iid, err := intFromPayload(payload["merge_request_iid"]) + if err != nil { + return nil, err + } + mr, err := a.gitlab.GetMergeRequest(projectPath, iid) + if err != nil { + return nil, err + } + state := strings.ToLower(strings.TrimSpace(mr.State)) + if state != "merged" { + return nil, fmt.Errorf( + "merge request not merged (state=%q detailed_merge_status=%q merge_error=%q web_url=%s)", + mr.State, + mr.DetailedMergeStatus, + strings.TrimSpace(mr.MergeError), + mr.WebURL, + ) + } + return skillresult.Success(map[string]interface{}{ + "iid": mr.IID, + "web_url": mr.WebURL, + "state": mr.State, + }), nil + + case "gitlab.repo.checkout_mr": + projectPath := stringFromPayload(payload, "project_path", a.cfg.GitLab.ProjectPath) + iid, err := intFromPayload(payload["merge_request_iid"]) + if err != nil { + return nil, err + } + targetPath, _ := payload["target_path"].(string) + if strings.TrimSpace(targetPath) == "" { + return nil, fmt.Errorf("target_path is required") + } + credsFile, _ := payload["credentials_env_file"].(string) + if strings.TrimSpace(credsFile) == "" { + return nil, fmt.Errorf("credentials_env_file is required") + } + envMap, err := gitlab.LoadEnvFile(credsFile) + if err != nil { + return nil, fmt.Errorf("load credentials env file: %w", err) + } + token := strings.TrimSpace(envMap["FLUID_GITLAB_SA_TOKEN"]) + if token == "" { + token = strings.TrimSpace(envMap["GITLAB_TOKEN"]) + } + if token == "" { + return nil, fmt.Errorf("FLUID_GITLAB_SA_TOKEN or GITLAB_TOKEN missing in credentials env file") + } + host, err := a.resolveGitLabHost(payload) + if err != nil { + return nil, err + } + runAs := stringFromPayload(payload, "run_as", "") + if err := gitlab.CheckoutMergeRequestHead(host, projectPath, token, targetPath, iid, runAs); err != nil { + return nil, err + } + if chownTo := stringFromPayload(payload, "chown_repo_to", ""); chownTo != "" && strings.TrimSpace(runAs) == "" { + if err := gitlab.ChownPathRecursive(targetPath, chownTo); err != nil { + return nil, err + } + } + return skillresult.Success(map[string]interface{}{ + "target_path": targetPath, + "merge_request_iid": iid, + "ref": fmt.Sprintf("merge-requests/%d/head", iid), + }), nil + + default: + return nil, fmt.Errorf("unsupported skill: %s", skill) + } +} + +func stringFromPayload(payload map[string]interface{}, key, fallback string) string { + if v, ok := payload[key].(string); ok && strings.TrimSpace(v) != "" { + return strings.TrimSpace(v) + } + return fallback +} + +func intFromPayload(v interface{}) (int, error) { + switch n := v.(type) { + case float64: + return int(n), nil + case int: + return n, nil + case int64: + return int(n), nil + case string: + // Allow string IIDs from interpolated YAML payloads. + var x int + _, err := fmt.Sscanf(strings.TrimSpace(n), "%d", &x) + if err != nil || x <= 0 { + return 0, fmt.Errorf("merge_request_iid must be a positive integer") + } + return x, nil + default: + return 0, fmt.Errorf("merge_request_iid must be a number") + } +} + +func (a *Agent) resolveGitLabHost(payload map[string]interface{}) (string, error) { + if h, ok := payload["gitlab_host"].(string); ok && strings.TrimSpace(h) != "" { + return strings.TrimSpace(h), nil + } + u, err := url.Parse(a.cfg.GitLab.URL) + if err != nil { + return "", err + } + if u.Host == "" { + return "", fmt.Errorf("gitlab_host missing and gitlab.url has no host") + } + return u.Host, nil +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..25a4b44 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,182 @@ +package config + +import ( + "fmt" + "net/url" + "os" + "strings" + + "gopkg.in/yaml.v3" + + "fluid/agents/core/enroll" +) + +type Config struct { + Agent AgentConfig `yaml:"agent"` + Enrollment EnrollmentConfig `yaml:"enrollment,omitempty"` + GitLab GitLabConfig `yaml:"gitlab"` + Controlplane ControlplaneConfig `yaml:"controlplane"` + Logs LogsConfig `yaml:"logs"` + Skills SkillsConfig `yaml:"skills"` + // ServiceCredentials is "local" (default) or "control_plane" (GitLab token from CP after connect). + ServiceCredentials string `yaml:"service_credentials,omitempty"` +} + +const ( + ServiceCredentialsLocal = "local" + ServiceCredentialsControlPlane = "control_plane" + gitlabServiceCredsEnv = "FLUID_SERVICE_CREDENTIALS" +) + +// ServiceCredentialsMode returns normalized service_credentials (default: local). Override with FLUID_SERVICE_CREDENTIALS. +func (c *Config) ServiceCredentialsMode() string { + if v := strings.TrimSpace(os.Getenv(gitlabServiceCredsEnv)); v != "" { + return strings.ToLower(v) + } + s := strings.ToLower(strings.TrimSpace(c.ServiceCredentials)) + if s == "" { + return ServiceCredentialsLocal + } + return s +} + +// EnrollmentConfig holds optional first-boot enrollment overrides (read before POST /enrollment/enroll). +// Name is the control plane Agent.name (JSON "name" on enroll). +// DeprecatedDisplayName maps YAML key display_name (deprecated; prefer name). +type EnrollmentConfig struct { + Name string `yaml:"name"` + DeprecatedDisplayName string `yaml:"display_name,omitempty"` +} + +type AgentConfig struct { + Name string `yaml:"name"` + Version string `yaml:"version"` + Mode string `yaml:"mode"` +} + +type GitLabConfig struct { + URL string `yaml:"url"` + Token string `yaml:"token"` + APIv string `yaml:"api_version"` + ProjectPath string `yaml:"project_path"` + TargetBranch string `yaml:"target_branch"` +} + +type ControlplaneConfig struct { + WebSocketURL string `yaml:"websocket_url"` + OrganizationUUID string `yaml:"organization_uuid"` + Token string `yaml:"token"` +} + +type SkillsConfig struct { + Allowed []string `yaml:"allowed"` + Definitions map[string]interface{} `yaml:"definitions,omitempty"` +} + +type LogsConfig struct { + Enabled *bool `yaml:"enabled"` + Verbosity string `yaml:"verbosity"` +} + +func LoadConfig(path string) (*Config, error) { + raw, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read config: %w", err) + } + + var cfg Config + if err := yaml.Unmarshal(raw, &cfg); err != nil { + return nil, fmt.Errorf("decode config: %w", err) + } + + cfg.resolveEnvVars() + cfg.applyDefaults() + // Do not call Validate here: first-boot enrollment supplies organization_uuid and token after POST /enrollment/enroll. + return &cfg, nil +} + +func (c *Config) resolveEnvVars() { + c.ServiceCredentials = resolve(c.ServiceCredentials) + c.Enrollment.Name = resolve(c.Enrollment.Name) + c.Enrollment.DeprecatedDisplayName = resolve(c.Enrollment.DeprecatedDisplayName) + c.GitLab.Token = resolve(c.GitLab.Token) + c.Controlplane.WebSocketURL = resolve(c.Controlplane.WebSocketURL) + c.Controlplane.OrganizationUUID = resolve(c.Controlplane.OrganizationUUID) + c.Controlplane.Token = resolve(c.Controlplane.Token) + c.resolveControlplaneFromRegistrationURL() +} + +func (c *Config) resolveControlplaneFromRegistrationURL() { + if c.Controlplane.WebSocketURL == "" { + return + } + + u, err := url.Parse(c.Controlplane.WebSocketURL) + if err != nil { + return + } + + q := u.Query() + if c.Controlplane.OrganizationUUID == "" { + c.Controlplane.OrganizationUUID = q.Get("organization_uuid") + } + if c.Controlplane.Token == "" { + c.Controlplane.Token = q.Get("token") + } + + if q.Has("organization_uuid") || q.Has("token") { + q.Del("organization_uuid") + q.Del("token") + u.RawQuery = q.Encode() + c.Controlplane.WebSocketURL = u.String() + } +} + +func (c *Config) applyDefaults() { + if c.Logs.Enabled == nil { + v := true + c.Logs.Enabled = &v + } + if strings.TrimSpace(c.Logs.Verbosity) == "" { + c.Logs.Verbosity = "normal" + } +} + +func (c *Config) Validate() error { + if c.Agent.Mode == "" { + c.Agent.Mode = "execution" + } + if c.Agent.Mode != "execution" { + return fmt.Errorf("agent.mode must be execution") + } + if c.Controlplane.WebSocketURL == "" || c.Controlplane.OrganizationUUID == "" || c.Controlplane.Token == "" { + return fmt.Errorf("controlplane websocket_url, organization_uuid and token are required") + } + if c.GitLab.URL == "" || c.GitLab.ProjectPath == "" || c.GitLab.TargetBranch == "" { + return fmt.Errorf("gitlab url, project_path and target_branch are required") + } + mode := c.ServiceCredentialsMode() + switch mode { + case ServiceCredentialsLocal, ServiceCredentialsControlPlane: + default: + return fmt.Errorf("service_credentials must be %q or %q (got %q)", ServiceCredentialsLocal, ServiceCredentialsControlPlane, mode) + } + if mode == ServiceCredentialsLocal && strings.TrimSpace(c.GitLab.Token) == "" { + return fmt.Errorf("gitlab token is required when service_credentials=%s", ServiceCredentialsLocal) + } + if mode == ServiceCredentialsControlPlane && enroll.RunIDFromPrefetchSources() == "" { + return fmt.Errorf("service_credentials=%s requires use_case_run_id (FLUID_USE_CASE_RUN_ID or FLUID_ENROLLMENT_EXTRA_ARGS JSON)", ServiceCredentialsControlPlane) + } + if len(c.Skills.Allowed) == 0 { + return fmt.Errorf("skills.allowed must contain at least one skill") + } + return nil +} + +func resolve(v string) string { + if strings.HasPrefix(v, "${") && strings.HasSuffix(v, "}") { + key := strings.TrimSuffix(strings.TrimPrefix(v, "${"), "}") + return os.Getenv(key) + } + return v +} diff --git a/internal/config/credentials.go b/internal/config/credentials.go new file mode 100644 index 0000000..b2e8cf8 --- /dev/null +++ b/internal/config/credentials.go @@ -0,0 +1,92 @@ +package config + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "gopkg.in/yaml.v3" +) + +type credentialsFile struct { + Controlplane ControlplaneConfig `yaml:"controlplane"` +} + +// MergeCredentialsFromFile loads durable control plane credentials written after enrollment. +// If the file does not exist, it returns nil. +func MergeCredentialsFromFile(cfg *Config, path string) error { + path = strings.TrimSpace(path) + if path == "" { + return nil + } + raw, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return fmt.Errorf("read credentials file: %w", err) + } + var f credentialsFile + if err := yaml.Unmarshal(raw, &f); err != nil { + return fmt.Errorf("decode credentials file: %w", err) + } + cp := f.Controlplane + if strings.TrimSpace(cp.WebSocketURL) != "" { + cfg.Controlplane.WebSocketURL = strings.TrimSpace(cp.WebSocketURL) + } + if strings.TrimSpace(cp.OrganizationUUID) != "" { + cfg.Controlplane.OrganizationUUID = strings.TrimSpace(cp.OrganizationUUID) + } + if strings.TrimSpace(cp.Token) != "" { + cfg.Controlplane.Token = strings.TrimSpace(cp.Token) + } + return nil +} + +// WriteCredentialsFile writes durable WebSocket + organization + connection token (0600, atomic replace). +func WriteCredentialsFile(path string, cp ControlplaneConfig) error { + path = strings.TrimSpace(path) + if path == "" { + return fmt.Errorf("credentials path is empty") + } + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("mkdir credentials dir: %w", err) + } + out, err := yaml.Marshal(&credentialsFile{ + Controlplane: ControlplaneConfig{ + WebSocketURL: strings.TrimSpace(cp.WebSocketURL), + OrganizationUUID: strings.TrimSpace(cp.OrganizationUUID), + Token: strings.TrimSpace(cp.Token), + }, + }) + if err != nil { + return fmt.Errorf("marshal credentials: %w", err) + } + tmp, err := os.CreateTemp(dir, ".fluid-credentials-*") + if err != nil { + return fmt.Errorf("create temp credentials: %w", err) + } + tmpName := tmp.Name() + _ = tmp.Chmod(0o600) + if _, err := tmp.Write(out); err != nil { + _ = tmp.Close() + _ = os.Remove(tmpName) + return fmt.Errorf("write temp credentials: %w", err) + } + if err := tmp.Sync(); err != nil { + _ = tmp.Close() + _ = os.Remove(tmpName) + return fmt.Errorf("sync temp credentials: %w", err) + } + if err := tmp.Close(); err != nil { + _ = os.Remove(tmpName) + return fmt.Errorf("close temp credentials: %w", err) + } + if err := os.Rename(tmpName, path); err != nil { + _ = os.Remove(tmpName) + return fmt.Errorf("replace credentials file: %w", err) + } + return nil +} diff --git a/internal/config/envfile_merge.go b/internal/config/envfile_merge.go new file mode 100644 index 0000000..ccb067e --- /dev/null +++ b/internal/config/envfile_merge.go @@ -0,0 +1,49 @@ +package config + +import ( + "bufio" + "fmt" + "os" + "strings" +) + +// MergeDotenvIntoGitLab parses KEY=value lines and applies GITLAB_TOKEN (or FLUID_GITLAB_TOKEN) onto cfg.GitLab. +func MergeDotenvIntoGitLab(cfg *Config, path string) error { + if cfg == nil { + return fmt.Errorf("config is nil") + } + path = strings.TrimSpace(path) + if path == "" { + return nil + } + f, err := os.Open(path) + if err != nil { + return fmt.Errorf("open service credentials env file: %w", err) + } + defer f.Close() + + sc := bufio.NewScanner(f) + for sc.Scan() { + line := strings.TrimSpace(sc.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + k, v, ok := strings.Cut(line, "=") + if !ok { + continue + } + k = strings.TrimSpace(k) + v = strings.TrimSpace(v) + v = strings.Trim(v, `"'`) + switch k { + case "GITLAB_TOKEN", "FLUID_GITLAB_TOKEN": + if v != "" { + cfg.GitLab.Token = v + } + } + } + if err := sc.Err(); err != nil { + return fmt.Errorf("read service credentials env file: %w", err) + } + return nil +} diff --git a/internal/gitlab/client.go b/internal/gitlab/client.go new file mode 100644 index 0000000..a146003 --- /dev/null +++ b/internal/gitlab/client.go @@ -0,0 +1,155 @@ +package gitlab + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "sync" + "time" +) + +type Client struct { + mu sync.RWMutex + baseURL string + token string + apiVersion string + http *http.Client +} + +type APIError struct { + StatusCode int + Body string +} + +func (e *APIError) Error() string { + return fmt.Sprintf("gitlab API error status=%d", e.StatusCode) +} + +func New(baseURL, token, apiVersion string) *Client { + if apiVersion == "" { + apiVersion = "v4" + } + return &Client{ + baseURL: strings.TrimRight(baseURL, "/"), + token: token, + apiVersion: apiVersion, + http: &http.Client{Timeout: 30 * time.Second}, + } +} + +// SetToken replaces the bearer token used for subsequent GitLab API calls (e.g. after control-plane prefetch). +func (c *Client) SetToken(tok string) { + c.mu.Lock() + c.token = strings.TrimSpace(tok) + c.mu.Unlock() +} + +func (c *Client) bearer() string { + c.mu.RLock() + defer c.mu.RUnlock() + return c.token +} + +func (c *Client) setAuthHeader(req *http.Request) { + token := c.bearer() + if strings.HasPrefix(token, "glpat-") || strings.HasPrefix(token, "gloas-") || strings.HasPrefix(token, "glcbt-") { + req.Header.Set("PRIVATE-TOKEN", token) + return + } + req.Header.Set("Authorization", "Bearer "+token) +} + +func (c *Client) projectID(path string) string { + return url.PathEscape(path) +} + +func (c *Client) CreateBranch(projectPath, branch, ref string) error { + body := map[string]string{"branch": branch, "ref": ref} + return c.post(fmt.Sprintf("/projects/%s/repository/branches", c.projectID(projectPath)), body, nil) +} + +func (c *Client) CommitFiles(projectPath, branch, message string, actions []map[string]interface{}) error { + body := map[string]interface{}{ + "branch": branch, + "commit_message": message, + "actions": actions, + } + return c.post(fmt.Sprintf("/projects/%s/repository/commits", c.projectID(projectPath)), body, nil) +} + +func (c *Client) CreateMergeRequest(projectPath, source, target, title, description string) (string, error) { + resp := map[string]interface{}{} + body := map[string]interface{}{ + "source_branch": source, + "target_branch": target, + "title": title, + "description": description, + "remove_source_branch": true, + } + if err := c.post(fmt.Sprintf("/projects/%s/merge_requests", c.projectID(projectPath)), body, &resp); err != nil { + return "", err + } + if v, ok := resp["web_url"].(string); ok { + return v, nil + } + return "", fmt.Errorf("merge request created but missing web_url") +} + +func (c *Client) get(path string, out interface{}) error { + req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/api/%s%s", c.baseURL, c.apiVersion, path), nil) + if err != nil { + return err + } + c.setAuthHeader(req) + resp, err := c.http.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return apiError(resp) + } + if out != nil { + return json.NewDecoder(resp.Body).Decode(out) + } + return nil +} + +func (c *Client) post(path string, body interface{}, out interface{}) error { + return c.doJSON(http.MethodPost, path, body, out) +} + +func (c *Client) put(path string, body interface{}, out interface{}) error { + return c.doJSON(http.MethodPut, path, body, out) +} + +func (c *Client) doJSON(method, path string, body interface{}, out interface{}) error { + raw, _ := json.Marshal(body) + req, err := http.NewRequest(method, fmt.Sprintf("%s/api/%s%s", c.baseURL, c.apiVersion, path), bytes.NewReader(raw)) + if err != nil { + return err + } + c.setAuthHeader(req) + req.Header.Set("Content-Type", "application/json") + resp, err := c.http.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return apiError(resp) + } + if out != nil { + return json.NewDecoder(resp.Body).Decode(out) + } + return nil +} + +func apiError(resp *http.Response) error { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10)) + return &APIError{StatusCode: resp.StatusCode, Body: strings.TrimSpace(string(body))} +} diff --git a/internal/gitlab/client_test.go b/internal/gitlab/client_test.go new file mode 100644 index 0000000..adc810c --- /dev/null +++ b/internal/gitlab/client_test.go @@ -0,0 +1,232 @@ +package gitlab + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "testing" +) + +func TestClient_GetMergeRequest(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || r.URL.Path != "/api/v4/projects/g/p/merge_requests/42" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + if r.Header.Get("Authorization") != "Bearer tok" { + t.Fatalf("missing bearer") + } + _ = json.NewEncoder(w).Encode(MergeRequest{ + IID: 42, + SourceBranch: "dep-bump", + TargetBranch: "main", + WebURL: "https://gitlab.example/g/p/-/merge_requests/42", + State: "opened", + }) + })) + defer srv.Close() + + c := New(srv.URL, "tok", "v4") + mr, err := c.GetMergeRequest("g/p", 42) + if err != nil { + t.Fatal(err) + } + if mr.SourceBranch != "dep-bump" || mr.IID != 42 { + t.Fatalf("mr: %+v", mr) + } +} + +func TestClient_UsesPrivateTokenHeaderForGitLabPAT(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("PRIVATE-TOKEN"); got != "glpat-secret" { + t.Fatalf("PRIVATE-TOKEN = %q", got) + } + if got := r.Header.Get("Authorization"); got != "" { + t.Fatalf("Authorization should be empty for PAT, got %q", got) + } + _ = json.NewEncoder(w).Encode(MergeRequest{IID: 42, State: "opened"}) + })) + defer srv.Close() + + c := New(srv.URL, "glpat-secret", "v4") + if _, err := c.GetMergeRequest("g/p", 42); err != nil { + t.Fatal(err) + } +} + +func TestClient_UsesBearerHeaderForNonPATToken(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("Authorization"); got != "Bearer oauth-secret" { + t.Fatalf("Authorization = %q", got) + } + if got := r.Header.Get("PRIVATE-TOKEN"); got != "" { + t.Fatalf("PRIVATE-TOKEN should be empty for bearer token, got %q", got) + } + _ = json.NewEncoder(w).Encode(MergeRequest{IID: 42, State: "opened"}) + })) + defer srv.Close() + + c := New(srv.URL, "oauth-secret", "v4") + if _, err := c.GetMergeRequest("g/p", 42); err != nil { + t.Fatal(err) + } +} + +func TestClient_ApproveMergeRequest(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/api/v4/projects/g/p/merge_requests/7/approve" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + _ = json.NewEncoder(w).Encode(MergeResponse{IID: 7, WebURL: "https://x/m/7", State: "opened"}) + })) + defer srv.Close() + + c := New(srv.URL, "tok", "v4") + out, err := c.ApproveMergeRequest("g/p", 7) + if err != nil { + t.Fatal(err) + } + if out.IID != 7 || out.State != "opened" { + t.Fatalf("got %+v", out) + } +} + +func TestClient_ApproveMergeRequestAlreadyApprovedByCurrentUser(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodPost && r.URL.Path == "/api/v4/projects/g/p/merge_requests/7/approve": + w.WriteHeader(http.StatusUnauthorized) + _ = json.NewEncoder(w).Encode(map[string]string{"message": "401 Unauthorized"}) + case r.Method == http.MethodGet && r.URL.Path == "/api/v4/user": + _ = json.NewEncoder(w).Encode(User{ID: 42, Username: "bot"}) + case r.Method == http.MethodGet && r.URL.Path == "/api/v4/projects/g/p/merge_requests/7/approvals": + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "iid": 7, + "approved_by": []map[string]interface{}{ + {"user": map[string]interface{}{"id": 42, "username": "bot"}}, + }, + }) + case r.Method == http.MethodGet && r.URL.Path == "/api/v4/projects/g/p/merge_requests/7": + _ = json.NewEncoder(w).Encode(MergeRequest{IID: 7, WebURL: "https://x/m/7", State: "opened"}) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + defer srv.Close() + + c := New(srv.URL, "glpat-secret", "v4") + out, err := c.ApproveMergeRequest("g/p", 7) + if err != nil { + t.Fatal(err) + } + if out.IID != 7 || out.State != "opened" { + t.Fatalf("got %+v", out) + } +} + +func TestClient_ApproveMergeRequestUnauthorizedWhenNotAlreadyApproved(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodPost && r.URL.Path == "/api/v4/projects/g/p/merge_requests/7/approve": + w.WriteHeader(http.StatusUnauthorized) + _ = json.NewEncoder(w).Encode(map[string]string{"message": "401 Unauthorized"}) + case r.Method == http.MethodGet && r.URL.Path == "/api/v4/user": + _ = json.NewEncoder(w).Encode(User{ID: 42, Username: "bot"}) + case r.Method == http.MethodGet && r.URL.Path == "/api/v4/projects/g/p/merge_requests/7/approvals": + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "iid": 7, + "approved_by": []map[string]interface{}{}, + }) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + defer srv.Close() + + c := New(srv.URL, "glpat-secret", "v4") + _, err := c.ApproveMergeRequest("g/p", 7) + if err == nil { + t.Fatal("expected unauthorized error") + } + apiErr, ok := err.(*APIError) + if !ok || apiErr.StatusCode != http.StatusUnauthorized { + t.Fatalf("expected 401 APIError, got %T %v", err, err) + } +} + +func TestClient_MergeMergeRequest(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut || r.URL.Path != "/api/v4/projects/g/p/merge_requests/7/merge" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + _ = json.NewEncoder(w).Encode(MergeResponse{IID: 7, WebURL: "https://x/m/7", State: "merged"}) + })) + defer srv.Close() + + c := New(srv.URL, "tok", "v4") + out, err := c.MergeMergeRequest("g/p", 7, "merge msg") + if err != nil { + t.Fatal(err) + } + if out.State != "merged" { + t.Fatalf("got %+v", out) + } +} + +func TestClient_ListProjectMergeRequests(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || r.URL.Path != "/api/v4/projects/g/p/merge_requests" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + q := r.URL.Query() + if q.Get("per_page") != "20" || q.Get("state") != "opened" { + t.Fatalf("unexpected query: %q", r.URL.RawQuery) + } + _ = json.NewEncoder(w).Encode([]MergeRequest{{IID: 1, State: "opened"}}) + })) + defer srv.Close() + + c := New(srv.URL, "tok", "v4") + list, err := c.ListProjectMergeRequests("g/p", "opened") + if err != nil { + t.Fatal(err) + } + if len(list) != 1 || list[0].IID != 1 { + t.Fatalf("list: %+v", list) + } +} + +func TestClient_ListMergeRequestPipelines(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + exp := "/api/v4/projects/g/p/merge_requests/3/pipelines" + if r.Method != http.MethodGet || r.URL.Path != exp { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + _ = json.NewEncoder(w).Encode([]Pipeline{{ID: 9, Status: "success", Ref: "main"}}) + })) + defer srv.Close() + + c := New(srv.URL, "tok", "v4") + pipes, err := c.ListMergeRequestPipelines("g/p", 3) + if err != nil { + t.Fatal(err) + } + if len(pipes) != 1 || pipes[0].ID != 9 { + t.Fatalf("pipes: %+v", pipes) + } +} + +func TestLoadEnvFile(t *testing.T) { + dir := t.TempDir() + path := dir + "/env" + if err := os.WriteFile(path, []byte("FLUID_GITLAB_SA_TOKEN=glpat-x\n# c\nEMPTY=\n"), 0o600); err != nil { + t.Fatal(err) + } + m, err := LoadEnvFile(path) + if err != nil { + t.Fatal(err) + } + if m["FLUID_GITLAB_SA_TOKEN"] != "glpat-x" { + t.Fatalf("%+v", m) + } +} diff --git a/internal/gitlab/merge_request.go b/internal/gitlab/merge_request.go new file mode 100644 index 0000000..a5ab4c1 --- /dev/null +++ b/internal/gitlab/merge_request.go @@ -0,0 +1,150 @@ +package gitlab + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" +) + +// MergeRequest holds fields used by execution skills (checkout, merge, assert). +type MergeRequest struct { + IID int `json:"iid"` + SourceBranch string `json:"source_branch"` + TargetBranch string `json:"target_branch"` + WebURL string `json:"web_url"` + State string `json:"state"` + MergeError string `json:"merge_error"` + DetailedMergeStatus string `json:"detailed_merge_status"` +} + +type User struct { + ID int `json:"id"` + Username string `json:"username"` +} + +type MergeRequestApprovals struct { + IID int `json:"iid"` + Approvals []struct { + User User `json:"user"` + } `json:"approved_by"` +} + +// GetMergeRequest loads a project MR by internal id (IID). +func (c *Client) GetMergeRequest(projectPath string, iid int) (*MergeRequest, error) { + apiPath := fmt.Sprintf("/projects/%s/merge_requests/%d", c.projectID(projectPath), iid) + var out MergeRequest + if err := c.get(apiPath, &out); err != nil { + return nil, err + } + return &out, nil +} + +func (c *Client) GetCurrentUser() (*User, error) { + var out User + if err := c.get("/user", &out); err != nil { + return nil, err + } + return &out, nil +} + +func (c *Client) GetMergeRequestApprovals(projectPath string, iid int) (*MergeRequestApprovals, error) { + apiPath := fmt.Sprintf("/projects/%s/merge_requests/%d/approvals", c.projectID(projectPath), iid) + var out MergeRequestApprovals + if err := c.get(apiPath, &out); err != nil { + return nil, err + } + return &out, nil +} + +// MergeResponse is the subset of fields returned by GitLab after an accept-MR call. +type MergeResponse struct { + IID int `json:"iid"` + WebURL string `json:"web_url"` + State string `json:"state"` +} + +// ApproveMergeRequest approves the merge request as the authenticated user (POST .../approve). +// See https://docs.gitlab.com/ee/api/merge_requests.html#approve-merge-request +func (c *Client) ApproveMergeRequest(projectPath string, iid int) (*MergeResponse, error) { + apiPath := fmt.Sprintf("/projects/%s/merge_requests/%d/approve", c.projectID(projectPath), iid) + var out MergeResponse + if err := c.post(apiPath, map[string]interface{}{}, &out); err != nil { + var apiErr *APIError + if errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusUnauthorized { + if approved, _, checkErr := c.MergeRequestApprovedByCurrentUser(projectPath, iid); checkErr == nil && approved { + mr, mrErr := c.GetMergeRequest(projectPath, iid) + if mrErr != nil { + return &MergeResponse{IID: iid, State: "opened"}, nil + } + return &MergeResponse{IID: mr.IID, WebURL: mr.WebURL, State: mr.State}, nil + } + } + return nil, err + } + return &out, nil +} + +func (c *Client) MergeRequestApprovedByCurrentUser(projectPath string, iid int) (bool, *User, error) { + user, err := c.GetCurrentUser() + if err != nil { + return false, nil, err + } + approvals, err := c.GetMergeRequestApprovals(projectPath, iid) + if err != nil { + return false, user, err + } + for _, approval := range approvals.Approvals { + if approval.User.ID != 0 && approval.User.ID == user.ID { + return true, user, nil + } + } + return false, user, nil +} + +// MergeMergeRequest accepts (merges) the merge request via the GitLab API (PUT .../merge). +func (c *Client) MergeMergeRequest(projectPath string, iid int, mergeCommitMessage string) (*MergeResponse, error) { + apiPath := fmt.Sprintf("/projects/%s/merge_requests/%d/merge", c.projectID(projectPath), iid) + body := map[string]interface{}{} + if mergeCommitMessage != "" { + body["merge_commit_message"] = mergeCommitMessage + } + var out MergeResponse + if err := c.put(apiPath, body, &out); err != nil { + return nil, err + } + return &out, nil +} + +// ListProjectMergeRequests returns merge requests for a project (used for automation / probes). +func (c *Client) ListProjectMergeRequests(projectPath string, state string) ([]MergeRequest, error) { + q := url.Values{} + if state != "" { + q.Set("state", state) + } + q.Set("per_page", "20") + suffix := "" + if enc := q.Encode(); enc != "" { + suffix = "?" + enc + } + apiPath := fmt.Sprintf("/projects/%s/merge_requests%s", c.projectID(projectPath), suffix) + req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/api/%s%s", c.baseURL, c.apiVersion, apiPath), nil) + if err != nil { + return nil, err + } + c.setAuthHeader(req) + resp, err := c.http.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, apiError(resp) + } + var out []MergeRequest + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return nil, err + } + return out, nil +} diff --git a/internal/gitlab/pipeline.go b/internal/gitlab/pipeline.go new file mode 100644 index 0000000..a2a5b43 --- /dev/null +++ b/internal/gitlab/pipeline.go @@ -0,0 +1,21 @@ +package gitlab + +import "fmt" + +// Pipeline is a minimal view of a GitLab CI pipeline (for listing / status checks). +type Pipeline struct { + ID int `json:"id"` + Status string `json:"status"` + Ref string `json:"ref"` + SHA string `json:"sha"` +} + +// ListMergeRequestPipelines returns pipelines for a merge request. +func (c *Client) ListMergeRequestPipelines(projectPath string, iid int) ([]Pipeline, error) { + apiPath := fmt.Sprintf("/projects/%s/merge_requests/%d/pipelines", c.projectID(projectPath), iid) + var out []Pipeline + if err := c.get(apiPath, &out); err != nil { + return nil, err + } + return out, nil +} diff --git a/internal/gitlab/repository.go b/internal/gitlab/repository.go new file mode 100644 index 0000000..c605eda --- /dev/null +++ b/internal/gitlab/repository.go @@ -0,0 +1,235 @@ +package gitlab + +import ( + "bufio" + "encoding/base64" + "fmt" + "net/url" + "os" + "os/exec" + "os/user" + "path/filepath" + "strconv" + "strings" + "syscall" +) + +// LoadEnvFile parses a simple KEY=value env file (one assignment per line, # comments). +func LoadEnvFile(path string) (map[string]string, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + + env := map[string]string{} + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + idx := strings.IndexByte(line, '=') + if idx <= 0 { + continue + } + k := strings.TrimSpace(line[:idx]) + v := strings.TrimSpace(line[idx+1:]) + if len(v) >= 2 && ((v[0] == '"' && v[len(v)-1] == '"') || (v[0] == '\'' && v[len(v)-1] == '\'')) { + v = v[1 : len(v)-1] + } + env[k] = v + } + if err := scanner.Err(); err != nil { + return nil, err + } + return env, nil +} + +// envForGitIdentity forces HOME/USER/LOGNAME for run_as so git never inherits root identity when euid drops. +func envForGitIdentity(env []string, login, home string) []string { + filtered := make([]string, 0, len(env)) + for _, e := range env { + if strings.HasPrefix(e, "HOME=") || strings.HasPrefix(e, "USER=") || strings.HasPrefix(e, "LOGNAME=") { + continue + } + filtered = append(filtered, e) + } + id := []string{ + fmt.Sprintf("HOME=%s", home), + fmt.Sprintf("USER=%s", login), + fmt.Sprintf("LOGNAME=%s", login), + } + return append(id, filtered...) +} + +func newGitCmd(runAs string, gitArgs ...string) (*exec.Cmd, error) { + cmd := exec.Command("git", gitArgs...) + runAs = strings.TrimSpace(runAs) + if runAs == "" { + return cmd, nil + } + urec, err := user.Lookup(runAs) + if err != nil { + return nil, fmt.Errorf("run_as: unknown user %q: %w", runAs, err) + } + uid, err := parseUint32ID("uid", urec.Uid) + if err != nil { + return nil, fmt.Errorf("run_as: %w", err) + } + gid, err := parseUint32ID("gid", urec.Gid) + if err != nil { + return nil, fmt.Errorf("run_as: %w", err) + } + euid := uint32(os.Geteuid()) + if euid != 0 { + if euid != uid { + return nil, fmt.Errorf("run_as=%q requires a root gitlab agent (euid=%d)", runAs, euid) + } + return cmd, nil + } + if uid != 0 { + h := strings.TrimSpace(urec.HomeDir) + if h != "" { + cmd.Env = envForGitIdentity(os.Environ(), urec.Username, h) + } + cmd.SysProcAttr = &syscall.SysProcAttr{ + Credential: &syscall.Credential{Uid: uid, Gid: gid}, + } + } + return cmd, nil +} + +func parseUint32ID(kind, value string) (uint32, error) { + parsed, err := strconv.ParseUint(strings.TrimSpace(value), 10, 32) + if err != nil { + return 0, fmt.Errorf("%s: %w", kind, err) + } + return uint32(parsed), nil +} + +func gitCombinedOutput(runAs string, gitArgs ...string) ([]byte, error) { + cmd, err := newGitCmd(runAs, gitArgs...) + if err != nil { + return nil, err + } + return cmd.CombinedOutput() +} + +func gitCombinedOutputWithAuth(runAs, authHeader string, gitArgs ...string) ([]byte, error) { + args := append([]string{"-c", "http.extraHeader=" + authHeader}, gitArgs...) + return gitCombinedOutput(runAs, args...) +} + +// CheckoutMergeRequestHead materializes the MR tip in targetPath using local git against GitLab HTTPS. +// Uses GitLab fetch ref merge-requests//head (same-repo MRs). +// When runAs is non-empty and the agent is root, git runs with that POSIX login (Credential); the repo parent dir must be writable by that user (e.g. debian.workspace.prepare workspace_owner). +func CheckoutMergeRequestHead(host, projectPath, token, targetPath string, mrIID int, runAs string) error { + if strings.TrimSpace(host) == "" || strings.TrimSpace(projectPath) == "" || strings.TrimSpace(token) == "" { + return fmt.Errorf("host, project_path and token are required") + } + targetAbs, err := filepath.Abs(targetPath) + if err != nil { + return err + } + ref := fmt.Sprintf("merge-requests/%d/head", mrIID) + branch := fmt.Sprintf("fluid-mr-%d", mrIID) + remote := httpsGitRemoteURL(host, projectPath) + authHeader := gitAuthHeader(token) + + gitDir := filepath.Join(targetAbs, ".git") + if st, err := os.Stat(gitDir); err == nil && st.IsDir() { + return fetchAndCheckoutExisting(targetAbs, remote, authHeader, ref, branch, runAs) + } + + if err := os.MkdirAll(targetAbs, 0o755); err != nil { + return err + } + entries, err := os.ReadDir(targetAbs) + if err != nil { + return err + } + if len(entries) > 0 { + return fmt.Errorf("target_path must be empty or an existing git clone: %s", targetAbs) + } + + if out, err := gitCombinedOutput(runAs, "-C", targetAbs, "init"); err != nil { + return fmt.Errorf("git init: %w: %s", err, strings.TrimSpace(string(out))) + } + if out, err := gitCombinedOutput(runAs, "-C", targetAbs, "remote", "add", "origin", remote); err != nil { + return fmt.Errorf("git remote add: %w: %s", err, strings.TrimSpace(string(out))) + } + fetchRef := fmt.Sprintf("%s:%s", ref, branch) + if out, err := gitCombinedOutputWithAuth(runAs, authHeader, "-C", targetAbs, "fetch", "origin", fetchRef); err != nil { + return fmt.Errorf("git fetch: %w: %s", err, strings.TrimSpace(string(out))) + } + if out, err := gitCombinedOutput(runAs, "-C", targetAbs, "checkout", branch); err != nil { + return fmt.Errorf("git checkout: %w: %s", err, strings.TrimSpace(string(out))) + } + return nil +} + +func fetchAndCheckoutExisting(dir, remote, authHeader, ref, branch, runAs string) error { + if out, err := gitCombinedOutput(runAs, "-C", dir, "remote", "set-url", "origin", remote); err != nil { + return fmt.Errorf("git remote set-url: %w: %s", err, strings.TrimSpace(string(out))) + } + fetchRef := fmt.Sprintf("%s:%s", ref, branch) + if out, err := gitCombinedOutputWithAuth(runAs, authHeader, "-C", dir, "fetch", "origin", fetchRef); err != nil { + return fmt.Errorf("git fetch: %w: %s", err, strings.TrimSpace(string(out))) + } + if out, err := gitCombinedOutput(runAs, "-C", dir, "checkout", branch); err != nil { + return fmt.Errorf("git checkout: %w: %s", err, strings.TrimSpace(string(out))) + } + return nil +} + +func httpsGitRemoteURL(host, projectPath string) string { + host = strings.TrimSpace(host) + projectPath = strings.Trim(projectPath, "/") + u := url.URL{ + Scheme: "https", + Host: host, + Path: "/" + projectPath + ".git", + } + return u.String() +} + +func gitAuthHeader(token string) string { + creds := "oauth2:" + strings.TrimSpace(token) + return "Authorization: Basic " + base64.StdEncoding.EncodeToString([]byte(creds)) +} + +// ChownPathRecursive runs chown -R uid:gid on path so a root git checkout can be handed to an +// unprivileged login (e.g. linux.script.run run_as). Path must resolve under /tmp/fluid/. +func ChownPathRecursive(path, login string) error { + login = strings.TrimSpace(login) + if login == "" { + return nil + } + abs, err := filepath.Abs(path) + if err != nil { + return fmt.Errorf("chown path abs: %w", err) + } + clean := filepath.Clean(abs) + if !strings.HasPrefix(clean, "/tmp/fluid/") && !strings.HasPrefix(clean, "/private/tmp/fluid/") { + return fmt.Errorf("chown path must be under /tmp/fluid (got %s)", clean) + } + u, err := user.Lookup(login) + if err != nil { + return fmt.Errorf("chown_repo_to: lookup %q: %w", login, err) + } + uid, err := strconv.Atoi(u.Uid) + if err != nil { + return fmt.Errorf("chown_repo_to: uid: %w", err) + } + gid, err := strconv.Atoi(u.Gid) + if err != nil { + return fmt.Errorf("chown_repo_to: gid: %w", err) + } + spec := fmt.Sprintf("%d:%d", uid, gid) + out, err := exec.Command("chown", "-R", spec, clean).CombinedOutput() + if err != nil { + return fmt.Errorf("chown -R %s %s: %w: %s", spec, clean, err, strings.TrimSpace(string(out))) + } + return nil +} diff --git a/internal/gitlab/repository_chown_test.go b/internal/gitlab/repository_chown_test.go new file mode 100644 index 0000000..1f07bf9 --- /dev/null +++ b/internal/gitlab/repository_chown_test.go @@ -0,0 +1,16 @@ +package gitlab + +import ( + "strings" + "testing" +) + +func TestChownPathRecursive_rejectsOutsideFluidTmp(t *testing.T) { + err := ChownPathRecursive("/etc/passwd", "nobody") + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "/tmp/fluid") { + t.Fatalf("unexpected error: %v", err) + } +} diff --git a/internal/gitlab/repository_runas_test.go b/internal/gitlab/repository_runas_test.go new file mode 100644 index 0000000..c585d38 --- /dev/null +++ b/internal/gitlab/repository_runas_test.go @@ -0,0 +1,60 @@ +package gitlab + +import ( + "math" + "strings" + "testing" +) + +func TestNewGitCmd_unknownRunAs(t *testing.T) { + _, err := newGitCmd("no_such_user_fluid_gitlab_xxxxx", "-C", "/tmp", "version") + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "run_as") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestNewGitCmd_emptyRunAs(t *testing.T) { + cmd, err := newGitCmd("", "-C", "/tmp", "version") + if err != nil { + t.Fatal(err) + } + if cmd == nil { + t.Fatal("nil cmd") + } + if cmd.SysProcAttr != nil && cmd.SysProcAttr.Credential != nil { + t.Fatal("expected no credential when run_as empty") + } +} + +func TestHTTPSGitRemoteURL_doesNotEmbedToken(t *testing.T) { + remote := httpsGitRemoteURL("gitlab.com", "constellio/infrastructure") + if strings.Contains(remote, "@") || strings.Contains(remote, "oauth2") { + t.Fatalf("remote should not contain credentials: %s", remote) + } + if remote != "https://gitlab.com/constellio/infrastructure.git" { + t.Fatalf("unexpected remote: %s", remote) + } +} + +func TestParseUint32ID_outOfRange(t *testing.T) { + _, err := parseUint32ID("uid", "4294967296") + if err == nil { + t.Fatal("expected out-of-range error") + } + if !strings.Contains(err.Error(), "uid") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestParseUint32ID_validMax(t *testing.T) { + got, err := parseUint32ID("gid", "4294967295") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != math.MaxUint32 { + t.Fatalf("unexpected gid: %d", got) + } +} diff --git a/scripts/install-git-hooks.sh b/scripts/install-git-hooks.sh new file mode 100644 index 0000000..ecfa954 --- /dev/null +++ b/scripts/install-git-hooks.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env sh +# Point Git at versioned hooks under .githooks (per-clone config, not committed). +set -euo pipefail + +cd "$(dirname "$0")/.." || exit 1 + +git config core.hooksPath .githooks +chmod +x .githooks/pre-commit 2>/dev/null || true +echo "Configured this clone: core.hooksPath=.githooks (pre-commit runs gofmt -l ., same as CI)."