Skip to content

Commit d8cead4

Browse files
Merge pull request #22 from actionforge/gh-mock-server
Add mock server for GitHub actions runtime services
2 parents 818bce1 + fd5efce commit d8cead4

15 files changed

Lines changed: 3316 additions & 0 deletions

File tree

cmd/cmd_root.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,13 +31,15 @@ var (
3131
flagEnvFile string
3232
flagCreateDebugSession bool
3333
flagLocal bool
34+
flagLocalGhServer bool
3435

3536
finalConfigFile string
3637
finalConcurrency string
3738
finalSessionToken string
3839
finalConfigValueSource string
3940
finalCreateDebugSession bool
4041
finalLocal bool
42+
finalLocalGhServer bool
4143

4244
finalGraphFile string
4345
finalGraphArgs []string
@@ -112,6 +114,7 @@ var cmdRoot = &cobra.Command{
112114
finalCreateDebugSession = finalCreateDebugSessionStr == "true" || finalCreateDebugSessionStr == "1"
113115

114116
finalLocal = flagLocal
117+
finalLocalGhServer = flagLocalGhServer
115118

116119
// the block below is used to distinguish between implicit graph files (eg if defined in an env var) + graph flags
117120
// vs explicit graph file (eg provided by positional arg) + graph flags.
@@ -201,6 +204,7 @@ func cmdRootRun(cmd *cobra.Command, args []string) {
201204
OverrideSecrets: nil,
202205
OverrideInputs: nil,
203206
Args: finalGraphArgs,
207+
LocalGhServer: finalLocalGhServer,
204208
}
205209

206210
if core.IsSharedGraphURL(finalGraphFile) {
@@ -253,6 +257,7 @@ func init() {
253257
cmdRoot.Flags().StringVar(&flagSessionToken, "session-token", "", "The session token from your browser")
254258
cmdRoot.Flags().BoolVar(&flagCreateDebugSession, "create-debug-session", false, "Create a debug session by connecting to the web app")
255259
cmdRoot.Flags().BoolVar(&flagLocal, "local", false, "Start a local WebSocket server for direct editor connection")
260+
cmdRoot.Flags().BoolVar(&flagLocalGhServer, "local-gh-server", false, "Start a local server mimicking GitHub Actions artifact, cache, and OIDC services")
256261

257262
// disable interspersed flag parsing to allow passing arbitrary flags to graphs.
258263
// it stops cobra from parsing flags once it hits positional argument

core/graph.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import (
1616
"sync"
1717
"time"
1818

19+
"github.com/actionforge/actrun-cli/github/server"
1920
"github.com/actionforge/actrun-cli/utils"
2021
"github.com/google/uuid"
2122

@@ -30,6 +31,7 @@ type RunOpts struct {
3031
OverrideInputs map[string]any
3132
OverrideEnv map[string]string
3233
Args []string
34+
LocalGhServer bool
3335
}
3436

3537
type ActionGraph struct {
@@ -471,6 +473,21 @@ func RunGraph(ctx context.Context, graphName string, graphContent []byte, opts R
471473
return CreateErr(nil, err, "failed to setup GitHub Actions environment")
472474
}
473475

476+
if opts.LocalGhServer {
477+
// RUNNER_TEMP is provided by the local editor over a 127.0.0.1-only WebSocket; not an external input.
478+
storageDir, mkErr := os.MkdirTemp(finalEnv["RUNNER_TEMP"], "gh-server-storage-") // lgtm[go/path-injection]
479+
if mkErr != nil {
480+
return CreateErr(nil, mkErr, "failed to create storage directory for local GitHub Actions server")
481+
}
482+
rs, srvErr := server.StartServer(server.Config{StorageDir: storageDir})
483+
if srvErr != nil {
484+
return CreateErr(nil, srvErr, "failed to start local GitHub Actions server")
485+
}
486+
defer rs.Stop()
487+
rs.InjectEnv(finalEnv)
488+
utils.LogOut.Infof("local GitHub Actions server started at %s\n", rs.URL)
489+
}
490+
474491
// Use the updated GITHUB_WORKSPACE as the working directory.
475492
// SetupGitHubActionsEnv replaces GITHUB_WORKSPACE with a fresh temp folder.
476493
if cwd, ok := finalEnv["GITHUB_WORKSPACE"]; ok {

github/server/cache.go

Lines changed: 298 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,298 @@
1+
package server
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"net/http"
7+
"os"
8+
"path/filepath"
9+
"strconv"
10+
"strings"
11+
"sync"
12+
"time"
13+
)
14+
15+
type CacheEntry struct {
16+
ID int64
17+
Key string
18+
Version string
19+
Scope string
20+
Size int64
21+
Finalized bool
22+
CreatedAt time.Time
23+
}
24+
25+
// --- Twirp dispatcher ---
26+
27+
func (s *Server) handleCacheTwirp(w http.ResponseWriter, r *http.Request) {
28+
if ct := r.Header.Get("Content-Type"); !strings.HasPrefix(ct, "application/json") {
29+
writeTwirpError(w, http.StatusBadRequest, "invalid_argument", "Content-Type must be application/json")
30+
return
31+
}
32+
33+
if _, _, err := parseJWT(r.Header.Get("Authorization")); err != nil {
34+
writeTwirpError(w, http.StatusUnauthorized, "unauthenticated", err.Error())
35+
return
36+
}
37+
38+
method := r.PathValue("method")
39+
switch method {
40+
case "CreateCacheEntry":
41+
s.handleCreateCacheEntry(w, r)
42+
case "FinalizeCacheEntryUpload":
43+
s.handleFinalizeCacheEntry(w, r)
44+
case "GetCacheEntryDownloadURL":
45+
s.handleGetCacheEntryDownloadURL(w, r)
46+
case "DeleteCacheEntry":
47+
s.handleDeleteCacheEntry(w, r)
48+
default:
49+
writeTwirpError(w, http.StatusNotFound, "not_found", fmt.Sprintf("unknown method: %s", method))
50+
}
51+
}
52+
53+
// --- Request/Response types ---
54+
55+
type CacheMetadata struct {
56+
RepositoryID string `json:"repository_id"`
57+
Scope string `json:"scope"`
58+
}
59+
60+
type CreateCacheEntryRequest struct {
61+
Key string `json:"key"`
62+
Version string `json:"version"`
63+
Metadata *CacheMetadata `json:"metadata,omitempty"`
64+
}
65+
66+
type CreateCacheEntryResponse struct {
67+
Ok bool `json:"ok"`
68+
SignedUploadURL string `json:"signed_upload_url"`
69+
}
70+
71+
// FlexInt64 unmarshals from both JSON numbers and JSON strings.
72+
// Protobuf's canonical JSON encoding represents int64 as strings.
73+
type FlexInt64 int64
74+
75+
func (f *FlexInt64) UnmarshalJSON(data []byte) error {
76+
var n int64
77+
if err := json.Unmarshal(data, &n); err == nil {
78+
*f = FlexInt64(n)
79+
return nil
80+
}
81+
var s string
82+
if err := json.Unmarshal(data, &s); err != nil {
83+
return fmt.Errorf("FlexInt64: cannot unmarshal %s", string(data))
84+
}
85+
n, err := strconv.ParseInt(s, 10, 64)
86+
if err != nil {
87+
return fmt.Errorf("FlexInt64: invalid int64 string %q: %w", s, err)
88+
}
89+
*f = FlexInt64(n)
90+
return nil
91+
}
92+
93+
type FinalizeCacheEntryRequest struct {
94+
Key string `json:"key"`
95+
Version string `json:"version"`
96+
SizeBytes FlexInt64 `json:"size_bytes"`
97+
}
98+
99+
type FinalizeCacheEntryResponse struct {
100+
Ok bool `json:"ok"`
101+
EntryID string `json:"entry_id"`
102+
}
103+
104+
type GetCacheEntryDownloadURLRequest struct {
105+
Metadata *CacheMetadata `json:"metadata,omitempty"`
106+
Key string `json:"key"`
107+
RestoreKeys []string `json:"restore_keys,omitempty"`
108+
Version string `json:"version"`
109+
}
110+
111+
type GetCacheEntryDownloadURLResponse struct {
112+
Ok bool `json:"ok"`
113+
SignedDownloadURL string `json:"signed_download_url"`
114+
MatchedKey string `json:"matched_key"`
115+
}
116+
117+
type DeleteCacheEntryRequest struct {
118+
Key string `json:"key"`
119+
Version string `json:"version"`
120+
}
121+
122+
type DeleteCacheEntryResponse struct {
123+
Ok bool `json:"ok"`
124+
}
125+
126+
// --- RPC handlers ---
127+
128+
func (s *Server) handleCreateCacheEntry(w http.ResponseWriter, r *http.Request) {
129+
var req CreateCacheEntryRequest
130+
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
131+
writeTwirpError(w, http.StatusBadRequest, "invalid_argument", "invalid JSON")
132+
return
133+
}
134+
if req.Key == "" || req.Version == "" {
135+
writeTwirpError(w, http.StatusBadRequest, "invalid_argument", "key and version are required")
136+
return
137+
}
138+
139+
scope := ""
140+
if req.Metadata != nil {
141+
scope = req.Metadata.Scope
142+
}
143+
cacheKey := scope + "/" + req.Key + "/" + req.Version
144+
145+
s.mu.Lock()
146+
// If entry already exists, overwrite (caches are mutable)
147+
if existing, ok := s.caches[cacheKey]; ok {
148+
delete(s.cacheByID, existing.ID)
149+
delete(s.uploadMu, existing.ID)
150+
}
151+
id := s.nextID
152+
s.nextID++
153+
entry := &CacheEntry{
154+
ID: id,
155+
Key: req.Key,
156+
Version: req.Version,
157+
Scope: scope,
158+
CreatedAt: time.Now(),
159+
}
160+
s.caches[cacheKey] = entry
161+
s.cacheByID[id] = entry
162+
s.uploadMu[id] = &sync.Mutex{}
163+
s.mu.Unlock()
164+
165+
uploadURL := s.makeSignedURL("PUT", id)
166+
writeJSON(w, http.StatusOK, CreateCacheEntryResponse{
167+
Ok: true,
168+
SignedUploadURL: uploadURL,
169+
})
170+
}
171+
172+
func (s *Server) handleFinalizeCacheEntry(w http.ResponseWriter, r *http.Request) {
173+
var req FinalizeCacheEntryRequest
174+
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
175+
writeTwirpError(w, http.StatusBadRequest, "invalid_argument", "invalid JSON")
176+
return
177+
}
178+
179+
s.mu.Lock()
180+
var found *CacheEntry
181+
for _, entry := range s.caches {
182+
if entry.Key == req.Key && entry.Version == req.Version {
183+
found = entry
184+
break
185+
}
186+
}
187+
if found == nil {
188+
s.mu.Unlock()
189+
writeTwirpError(w, http.StatusNotFound, "not_found", "cache entry not found")
190+
return
191+
}
192+
found.Size = int64(req.SizeBytes)
193+
found.Finalized = true
194+
s.mu.Unlock()
195+
196+
writeJSON(w, http.StatusOK, FinalizeCacheEntryResponse{
197+
Ok: true,
198+
EntryID: strconv.FormatInt(found.ID, 10),
199+
})
200+
}
201+
202+
func (s *Server) handleGetCacheEntryDownloadURL(w http.ResponseWriter, r *http.Request) {
203+
var req GetCacheEntryDownloadURLRequest
204+
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
205+
writeTwirpError(w, http.StatusBadRequest, "invalid_argument", "invalid JSON")
206+
return
207+
}
208+
209+
scope := ""
210+
if req.Metadata != nil {
211+
scope = req.Metadata.Scope
212+
}
213+
214+
type match struct {
215+
id int64
216+
key string
217+
}
218+
219+
var found *match
220+
221+
s.mu.RLock()
222+
// 1. Exact match: scope + key + version
223+
exactKey := scope + "/" + req.Key + "/" + req.Version
224+
if entry, ok := s.caches[exactKey]; ok && entry.Finalized {
225+
found = &match{id: entry.ID, key: entry.Key}
226+
}
227+
228+
// 2. Prefix match with restore_keys
229+
if found == nil {
230+
for _, rk := range req.RestoreKeys {
231+
var best *CacheEntry
232+
for _, entry := range s.caches {
233+
if entry.Scope != scope || entry.Version != req.Version {
234+
continue
235+
}
236+
if !entry.Finalized {
237+
continue
238+
}
239+
if !strings.HasPrefix(entry.Key, rk) {
240+
continue
241+
}
242+
if best == nil || entry.CreatedAt.After(best.CreatedAt) {
243+
best = entry
244+
}
245+
}
246+
if best != nil {
247+
found = &match{id: best.ID, key: best.Key}
248+
break
249+
}
250+
}
251+
}
252+
s.mu.RUnlock()
253+
254+
if found != nil {
255+
downloadURL := s.makeSignedURL("GET", found.id)
256+
writeJSON(w, http.StatusOK, GetCacheEntryDownloadURLResponse{
257+
Ok: true,
258+
SignedDownloadURL: downloadURL,
259+
MatchedKey: found.key,
260+
})
261+
return
262+
}
263+
264+
writeTwirpError(w, http.StatusNotFound, "not_found", "cache entry not found")
265+
}
266+
267+
func (s *Server) handleDeleteCacheEntry(w http.ResponseWriter, r *http.Request) {
268+
var req DeleteCacheEntryRequest
269+
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
270+
writeTwirpError(w, http.StatusBadRequest, "invalid_argument", "invalid JSON")
271+
return
272+
}
273+
274+
s.mu.Lock()
275+
var found *CacheEntry
276+
var foundKey string
277+
for k, entry := range s.caches {
278+
if entry.Key == req.Key && entry.Version == req.Version {
279+
found = entry
280+
foundKey = k
281+
break
282+
}
283+
}
284+
if found == nil {
285+
s.mu.Unlock()
286+
writeTwirpError(w, http.StatusNotFound, "not_found", "cache entry not found")
287+
return
288+
}
289+
delete(s.caches, foundKey)
290+
delete(s.cacheByID, found.ID)
291+
delete(s.uploadMu, found.ID)
292+
s.mu.Unlock()
293+
294+
blobPath := filepath.Join(s.storageDir, fmt.Sprintf("%d.blob", found.ID))
295+
os.Remove(blobPath)
296+
297+
writeJSON(w, http.StatusOK, DeleteCacheEntryResponse{Ok: true})
298+
}

0 commit comments

Comments
 (0)