Skip to content

Commit 7bb6fb9

Browse files
Merge pull request #35 from actionforge/agent
Add agent execution, Perforce integration, and Docker publishing
2 parents be50be6 + 029dc24 commit 7bb6fb9

133 files changed

Lines changed: 19374 additions & 133 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/graphs/build-test-publish.act

Lines changed: 356 additions & 99 deletions
Large diffs are not rendered by default.

.github/workflows/workflow.yml

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ permissions:
2929
# TODO: (Seb) Use fine-grained permissions as
3030
# we only need this for Anchore SBOM Action
3131
contents: write
32+
packages: write
3233

3334
jobs:
3435
build-quick:
@@ -72,7 +73,7 @@ jobs:
7273
strategy:
7374
matrix:
7475
license: [free] # add pro when ready
75-
os: [ubuntu-latest, windows-latest, macos-latest]
76+
os: [windows-latest, ubuntu-latest, ubuntu-24.04-arm, macos-latest]
7677

7778
runs-on: ${{ matrix.os }}
7879

@@ -101,4 +102,23 @@ jobs:
101102
graph-file: build-test-publish.act
102103
inputs: ${{ toJson(inputs) }}
103104
secrets: ${{ toJson(secrets) }}
104-
matrix: ${{ toJson(matrix) }}
105+
matrix: ${{ toJson(matrix) }}
106+
107+
docker-manifest:
108+
name: Create Docker Multi-Arch Manifest
109+
needs: build-test-publish
110+
if: startsWith(github.ref, 'refs/tags/') && (github.event_name == 'workflow_dispatch' || (github.event_name == 'push'))
111+
runs-on: ubuntu-latest
112+
steps:
113+
- name: Create multi-arch manifest
114+
run: |
115+
IMAGE="ghcr.io/actionforge/actrun"
116+
VERSION="${GITHUB_REF_NAME}"
117+
118+
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin
119+
120+
docker buildx imagetools create \
121+
-t "$IMAGE:$VERSION" \
122+
-t "$IMAGE:latest" \
123+
"$IMAGE:${VERSION}-x64" \
124+
"$IMAGE:${VERSION}-arm64"

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ __pycache__/
3030
.DS_Store
3131

3232
tests_e2e/coverage
33+
tests_e2e/coverage.html
3334

3435
# Output of the go coverage tool, specifically when used with LiteIDE
3536
*.out

Dockerfile

Lines changed: 48 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@
22
## Build
33
##
44

5-
FROM golang:1.25.0-alpine3.22 AS build
5+
FROM golang:1.25.0 AS build
6+
7+
ARG TARGETARCH
8+
9+
RUN apt-get update && apt-get install -y --no-install-recommends gcc g++ libssl-dev && rm -rf /var/lib/apt/lists/*
610

711
WORKDIR /app
812

@@ -12,18 +16,57 @@ RUN go mod download
1216

1317
COPY . ./
1418

15-
RUN go build -o ./bin/actrun
19+
RUN ARCH=$([ "$TARGETARCH" = "arm64" ] && echo "arm64" || echo "x64") && \
20+
bash setup.sh linux "$ARCH" && \
21+
P4_INCLUDE="$(pwd)/p4api/include" && \
22+
if [ "$TARGETARCH" = "arm64" ]; then P4_LIB="$(pwd)/p4api/linux-aarch64/lib"; \
23+
else P4_LIB="$(pwd)/p4api/linux-x86_64/lib"; fi && \
24+
CGO_ENABLED=1 \
25+
CGO_CPPFLAGS="-I$P4_INCLUDE" \
26+
CGO_LDFLAGS="-L$P4_LIB -lp4api -lssl -lcrypto" \
27+
go build -tags=p4 -o ./bin/actrun
1628

1729
##
1830
## Deploy
1931
##
2032

21-
FROM alpine:3.22.0
33+
FROM ubuntu:24.04
2234

2335
LABEL org.opencontainers.image.title="Graph Runner"
2436
LABEL org.opencontainers.image.description="Execution runtime for action graphs."
25-
LABEL org.opencontainers.image.version={{img.version}}
26-
LABEL org.opencontainers.image.source={{img.source}}
37+
ARG IMG_VERSION=dev
38+
ARG IMG_SOURCE=https://github.com/actionforge/actrun-cli
39+
40+
LABEL org.opencontainers.image.version=${IMG_VERSION}
41+
LABEL org.opencontainers.image.source=${IMG_SOURCE}
42+
43+
ARG TARGETARCH
44+
45+
RUN apt-get update && apt-get install -y --no-install-recommends \
46+
bash \
47+
ca-certificates \
48+
locales \
49+
curl \
50+
wget \
51+
jq \
52+
zip \
53+
unzip \
54+
tar \
55+
xz-utils \
56+
python3 \
57+
libicu74 libssl3t64 \
58+
&& PWSH_ARCH=$([ "$TARGETARCH" = "arm64" ] && echo "arm64" || echo "x64") \
59+
&& curl -fsSL "https://github.com/PowerShell/PowerShell/releases/download/v7.5.5/powershell-7.5.5-linux-${PWSH_ARCH}.tar.gz" \
60+
-o /tmp/pwsh.tar.gz \
61+
&& mkdir -p /opt/microsoft/powershell/7 \
62+
&& tar -xzf /tmp/pwsh.tar.gz -C /opt/microsoft/powershell/7 \
63+
&& chmod +x /opt/microsoft/powershell/7/pwsh \
64+
&& ln -s /opt/microsoft/powershell/7/pwsh /usr/bin/pwsh \
65+
&& rm /tmp/pwsh.tar.gz \
66+
&& sed -i 's/# en_US.UTF-8/en_US.UTF-8/' /etc/locale.gen && locale-gen \
67+
&& rm -rf /var/lib/apt/lists/*
68+
69+
ENV LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8
2770

2871
COPY --from=build /app/bin /bin
2972

README.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,26 @@ actrun --concurrency=false ./sequential_task.act
110110

111111
```
112112

113+
## 🔧 Perforce Support (Optional)
114+
115+
To build with Perforce (P4) support, you need the Perforce C/C++ API and OpenSSL installed.
116+
117+
1. Download the [Helix C/C++ API](https://www.perforce.com/downloads/helix-core-c/c++-api) and place it in `p4api/<os>/` (e.g. `p4api/macos/`).
118+
119+
2. Set the required environment variables before building:
120+
121+
```bash
122+
export CGO_CPPFLAGS="-I$(pwd)/p4api/macos/include -g"
123+
export CGO_LDFLAGS="-Wl,-no_warn_duplicate_libraries -L$(pwd)/p4api/macos/lib -L/opt/homebrew/opt/openssl@1.1/lib -lp4api -lssl -lcrypto -framework ApplicationServices -framework Foundation -framework Security"
124+
export CGO_ENABLED=1
125+
```
126+
127+
3. Build or run with the `p4` tag:
128+
129+
```bash
130+
go run -tags p4 . agent --token=<your-token> --server=<server-url>
131+
```
132+
113133
## 🛠️ Development Commands
114134

115135
If you are contributing to the core nodes or the CLI itself, the `dev` subcommand provides utilities to maintain the internal registry.

agent/client.go

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
package agent
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"fmt"
7+
"io"
8+
"net/http"
9+
"time"
10+
)
11+
12+
type Client struct {
13+
serverURL string
14+
token string
15+
httpClient *http.Client
16+
}
17+
18+
func NewClient(serverURL, token string) *Client {
19+
return &Client{
20+
serverURL: serverURL,
21+
token: token,
22+
httpClient: &http.Client{
23+
Timeout: 30 * time.Second,
24+
},
25+
}
26+
}
27+
28+
func (c *Client) doRequest(method, path string, body interface{}) (*http.Response, error) {
29+
var bodyReader io.Reader
30+
if body != nil {
31+
data, err := json.Marshal(body)
32+
if err != nil {
33+
return nil, err
34+
}
35+
bodyReader = bytes.NewReader(data)
36+
}
37+
38+
req, err := http.NewRequest(method, c.serverURL+path, bodyReader)
39+
if err != nil {
40+
return nil, err
41+
}
42+
req.Header.Set("Authorization", "Bearer "+c.token)
43+
if body != nil {
44+
req.Header.Set("Content-Type", "application/json")
45+
}
46+
return c.httpClient.Do(req)
47+
}
48+
49+
func (c *Client) ServerURL() string {
50+
return c.serverURL
51+
}
52+
53+
func (c *Client) Token() string {
54+
return c.token
55+
}
56+
57+
func (c *Client) Claim() (*ClaimResponse, error) {
58+
resp, err := c.doRequest("POST", "/api/v2/ci/runner/claim", nil)
59+
if err != nil {
60+
return nil, fmt.Errorf("claim request failed: %w", err)
61+
}
62+
defer resp.Body.Close()
63+
64+
if resp.StatusCode == http.StatusNoContent {
65+
return nil, nil
66+
}
67+
if resp.StatusCode != http.StatusOK {
68+
body, _ := io.ReadAll(resp.Body)
69+
return nil, fmt.Errorf("claim failed: %s %s", resp.Status, string(body))
70+
}
71+
72+
var claim ClaimResponse
73+
if err := json.NewDecoder(resp.Body).Decode(&claim); err != nil {
74+
return nil, fmt.Errorf("decode claim response: %w", err)
75+
}
76+
return &claim, nil
77+
}
78+
79+
// drainAndCheck reads the response body to allow connection reuse and returns
80+
// an error if the status code is not in the 2xx range.
81+
func drainAndCheck(resp *http.Response) error {
82+
_, _ = io.Copy(io.Discard, resp.Body)
83+
resp.Body.Close()
84+
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
85+
return fmt.Errorf("unexpected status: %s", resp.Status)
86+
}
87+
return nil
88+
}
89+
90+
// SendLogs sends a batch of log lines and returns the current job status from the server.
91+
func (c *Client) SendLogs(jobID string, batch LogBatch) (string, error) {
92+
resp, err := c.doRequest("POST", fmt.Sprintf("/api/v2/ci/runner/logs/%s", jobID), batch)
93+
if err != nil {
94+
return "", err
95+
}
96+
defer resp.Body.Close()
97+
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
98+
_, _ = io.Copy(io.Discard, resp.Body)
99+
return "", fmt.Errorf("unexpected status: %s", resp.Status)
100+
}
101+
var result struct {
102+
Status string `json:"status"`
103+
}
104+
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
105+
return "", nil // non-fatal: old server without status in response
106+
}
107+
return result.Status, nil
108+
}
109+
110+
func (c *Client) ReportStatus(jobID string, status RunStatus, exitCode *int) error {
111+
report := StatusReport{
112+
Status: status,
113+
ExitCode: exitCode,
114+
}
115+
resp, err := c.doRequest("PATCH", fmt.Sprintf("/api/v2/ci/runner/jobs/%s", jobID), report)
116+
if err != nil {
117+
return err
118+
}
119+
return drainAndCheck(resp)
120+
}
121+
122+
func (c *Client) SubmitGraph(jobID string, graph string) error {
123+
resp, err := c.doRequest("POST", fmt.Sprintf("/api/v2/ci/runner/jobs/%s/graph", jobID), map[string]string{"graph": graph})
124+
if err != nil {
125+
return err
126+
}
127+
return drainAndCheck(resp)
128+
}
129+
130+
func (c *Client) ReportRef(jobID, commitSHA string) error {
131+
resp, err := c.doRequest("POST", fmt.Sprintf("/api/v2/ci/runner/jobs/%s/ref", jobID), map[string]string{"commit_sha": commitSHA})
132+
if err != nil {
133+
return err
134+
}
135+
return drainAndCheck(resp)
136+
}
137+
138+
func (c *Client) SubmitActiveNodes(jobID string, nodes []ActiveNode) error {
139+
resp, err := c.doRequest("POST", fmt.Sprintf("/api/v2/ci/runner/jobs/%s/nodes", jobID), map[string]interface{}{"active_nodes": nodes})
140+
if err != nil {
141+
return err
142+
}
143+
return drainAndCheck(resp)
144+
}
145+
146+
func (c *Client) Heartbeat(req HeartbeatRequest) error {
147+
resp, err := c.doRequest("POST", "/api/v2/ci/runner/heartbeat", req)
148+
if err != nil {
149+
return err
150+
}
151+
return drainAndCheck(resp)
152+
}
153+
154+
155+
func (c *Client) Disconnect() error {
156+
resp, err := c.doRequest("POST", "/api/v2/ci/runner/disconnect", nil)
157+
if err != nil {
158+
return err
159+
}
160+
return drainAndCheck(resp)
161+
}

0 commit comments

Comments
 (0)