From c8269c5dd8c110ad7f92b8106c718848ee12ca0f Mon Sep 17 00:00:00 2001 From: Martin Catty Date: Tue, 26 May 2026 15:32:32 -0400 Subject: [PATCH 1/2] feat: initial public scaffold for agent-gitlab 0.1.0 Execution agent with GitLab API skills, repo checkout via git, agent-core submodule, CI/release via fluid-pub/actions, and GHCR image with git for checkout_mr. --- .dockerignore | 10 + .githooks/pre-commit | 22 ++ .github/dependabot.yml | 57 ++++ .github/workflows/ci.yml | 19 ++ .github/workflows/codeql.yml | 54 +++ .github/workflows/release-on-semver-tag.yml | 21 ++ .gitignore | 5 + .gitmodules | 6 + CHANGELOG.md | 20 ++ Dockerfile | 36 ++ Makefile | 40 +++ README.md | 83 +++++ SECURITY.md | 27 ++ cmd/main.go | 152 +++++++++ cmd/version.go | 3 + config/agent.example.yml | 112 ++++++ core | 1 + env.secrets.example | 18 + go.mod | 12 + go.sum | 6 + internal/agent/agent.go | 359 ++++++++++++++++++++ internal/config/config.go | 182 ++++++++++ internal/config/credentials.go | 92 +++++ internal/config/envfile_merge.go | 49 +++ internal/gitlab/client.go | 155 +++++++++ internal/gitlab/client_test.go | 232 +++++++++++++ internal/gitlab/merge_request.go | 150 ++++++++ internal/gitlab/pipeline.go | 21 ++ internal/gitlab/repository.go | 227 +++++++++++++ internal/gitlab/repository_chown_test.go | 16 + internal/gitlab/repository_runas_test.go | 39 +++ scripts/install-git-hooks.sh | 9 + 32 files changed, 2235 insertions(+) create mode 100644 .dockerignore create mode 100644 .githooks/pre-commit create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/codeql.yml create mode 100644 .github/workflows/release-on-semver-tag.yml create mode 100644 .gitignore create mode 100644 .gitmodules create mode 100644 CHANGELOG.md create mode 100644 Dockerfile create mode 100644 Makefile create mode 100644 README.md create mode 100644 SECURITY.md create mode 100644 cmd/main.go create mode 100644 cmd/version.go create mode 100644 config/agent.example.yml create mode 160000 core create mode 100644 env.secrets.example create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/agent/agent.go create mode 100644 internal/config/config.go create mode 100644 internal/config/credentials.go create mode 100644 internal/config/envfile_merge.go create mode 100644 internal/gitlab/client.go create mode 100644 internal/gitlab/client_test.go create mode 100644 internal/gitlab/merge_request.go create mode 100644 internal/gitlab/pipeline.go create mode 100644 internal/gitlab/repository.go create mode 100644 internal/gitlab/repository_chown_test.go create mode 100644 internal/gitlab/repository_runas_test.go create mode 100644 scripts/install-git-hooks.sh 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 new file mode 100644 index 0000000..9e1531b --- /dev/null +++ b/README.md @@ -0,0 +1,83 @@ +# 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..86b693c --- /dev/null +++ b/internal/gitlab/repository.go @@ -0,0 +1,227 @@ +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 := strconv.Atoi(urec.Uid) + if err != nil { + return nil, fmt.Errorf("run_as: uid: %w", err) + } + gid, err := strconv.Atoi(urec.Gid) + if err != nil { + return nil, fmt.Errorf("run_as: gid: %w", err) + } + euid := 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: uint32(uid), Gid: uint32(gid)}, + } + } + return cmd, 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. linux.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..552308c --- /dev/null +++ b/internal/gitlab/repository_runas_test.go @@ -0,0 +1,39 @@ +package gitlab + +import ( + "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) + } +} 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)." From d26efebd9fdcb79ea5ef876ef9ff44bd75f01619 Mon Sep 17 00:00:00 2001 From: Martin Catty Date: Wed, 27 May 2026 10:14:26 -0400 Subject: [PATCH 2/2] fix: bound-check run_as uid and gid conversions - Parse uid/gid with strconv.ParseUint(..., 32) before syscall.Credential cast. - Add tests for uint32 bounds behavior in parseUint32ID. --- internal/gitlab/repository.go | 22 +++++++++++++++------- internal/gitlab/repository_runas_test.go | 21 +++++++++++++++++++++ 2 files changed, 36 insertions(+), 7 deletions(-) diff --git a/internal/gitlab/repository.go b/internal/gitlab/repository.go index 86b693c..c605eda 100644 --- a/internal/gitlab/repository.go +++ b/internal/gitlab/repository.go @@ -73,15 +73,15 @@ func newGitCmd(runAs string, gitArgs ...string) (*exec.Cmd, error) { if err != nil { return nil, fmt.Errorf("run_as: unknown user %q: %w", runAs, err) } - uid, err := strconv.Atoi(urec.Uid) + uid, err := parseUint32ID("uid", urec.Uid) if err != nil { - return nil, fmt.Errorf("run_as: uid: %w", err) + return nil, fmt.Errorf("run_as: %w", err) } - gid, err := strconv.Atoi(urec.Gid) + gid, err := parseUint32ID("gid", urec.Gid) if err != nil { - return nil, fmt.Errorf("run_as: gid: %w", err) + return nil, fmt.Errorf("run_as: %w", err) } - euid := os.Geteuid() + 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) @@ -94,12 +94,20 @@ func newGitCmd(runAs string, gitArgs ...string) (*exec.Cmd, error) { cmd.Env = envForGitIdentity(os.Environ(), urec.Username, h) } cmd.SysProcAttr = &syscall.SysProcAttr{ - Credential: &syscall.Credential{Uid: uint32(uid), Gid: uint32(gid)}, + 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 { @@ -115,7 +123,7 @@ func gitCombinedOutputWithAuth(runAs, authHeader string, gitArgs ...string) ([]b // 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. linux.workspace.prepare workspace_owner). +// 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") diff --git a/internal/gitlab/repository_runas_test.go b/internal/gitlab/repository_runas_test.go index 552308c..c585d38 100644 --- a/internal/gitlab/repository_runas_test.go +++ b/internal/gitlab/repository_runas_test.go @@ -1,6 +1,7 @@ package gitlab import ( + "math" "strings" "testing" ) @@ -37,3 +38,23 @@ func TestHTTPSGitRemoteURL_doesNotEmbedToken(t *testing.T) { 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) + } +}