Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 97 additions & 0 deletions pkg/hub/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -4770,6 +4770,12 @@ func (s *Server) handleProjectRoutes(w http.ResponseWriter, r *http.Request) {
return
}

// Check for nested /import-harness-configs path
if subPath == "import-harness-configs" {
s.handleProjectImportHarnessConfigs(w, r, projectID)
return
}

// Check for nested /dav/ path (WebDAV endpoint for project workspace sync)
if strings.HasPrefix(subPath, "dav") {
davPath := strings.TrimPrefix(subPath, "dav")
Expand Down Expand Up @@ -9748,6 +9754,97 @@ func (s *Server) handleProjectImportTemplates(w http.ResponseWriter, r *http.Req
})
}

// ImportHarnessConfigsRequest is the request body for direct harness-config import.
// Exactly one of SourceURL or WorkspacePath should be provided.
type ImportHarnessConfigsRequest struct {
SourceURL string `json:"sourceUrl"`
WorkspacePath string `json:"workspacePath"`
}

// ImportHarnessConfigsResponse is returned after a direct harness-config import completes.
type ImportHarnessConfigsResponse struct {
HarnessConfigs []string `json:"harnessConfigs"`
Count int `json:"count"`
}

// handleProjectImportHarnessConfigs imports harness-configs directly from a
// remote URL or workspace path into the project's harness-config store.
func (s *Server) handleProjectImportHarnessConfigs(w http.ResponseWriter, r *http.Request, projectID string) {
if r.Method != http.MethodPost {
MethodNotAllowed(w)
return
}

ctx := r.Context()

if agentIdent := GetAgentIdentityFromContext(ctx); agentIdent != nil {
if !agentIdent.HasScope(ScopeAgentCreate) {
writeError(w, http.StatusForbidden, ErrCodeForbidden, "Missing required scope: project:agent:create", nil)
return
}
if projectID != agentIdent.ProjectID() {
writeError(w, http.StatusForbidden, ErrCodeForbidden, "Agents can only import harness-configs within their own project", nil)
return
}
} else if userIdent := GetUserIdentityFromContext(ctx); userIdent != nil {
decision := s.authzService.CheckAccess(ctx, userIdent, Resource{
Type: "harness_config",
ParentType: "project",
ParentID: projectID,
}, ActionCreate)
if !decision.Allowed {
writeError(w, http.StatusForbidden, ErrCodeForbidden,
"You don't have permission to import harness-configs in this project", nil)
return
}
} else {
writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required", nil)
return
}

var req ImportHarnessConfigsRequest
if err := readJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "Invalid request body", nil)
return
}

if req.SourceURL == "" && req.WorkspacePath == "" {
req.WorkspacePath = "/.scion/harness-configs"
}

project, err := s.store.GetProject(ctx, projectID)
if err != nil {
if err == store.ErrNotFound {
NotFound(w, "Project")
return
}
writeErrorFromErr(w, err, "")
return
}

if s.GetStorage() == nil {
writeError(w, http.StatusServiceUnavailable, "storage_unavailable", "Harness-config storage is not configured", nil)
return
}

var imported []string
if req.WorkspacePath != "" {
imported, err = s.importHarnessConfigsFromWorkspace(ctx, project, req.WorkspacePath)
} else {
req.SourceURL = config.NormalizeTemplateSourceURL(req.SourceURL)
imported, err = s.importHarnessConfigsFromRemote(ctx, projectID, req.SourceURL)
}
if err != nil {
writeError(w, http.StatusBadRequest, "import_failed", err.Error(), nil)
return
}

writeJSON(w, http.StatusOK, ImportHarnessConfigsResponse{
HarnessConfigs: imported,
Count: len(imported),
})
}

// ImportResourcesRequest is the body for the unified import endpoint
// (POST /api/v1/resources/import). It imports a single kind of resource from a
// remote source URL into the given scope.
Expand Down
195 changes: 195 additions & 0 deletions pkg/hub/resource_import_handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,201 @@ func TestHandleResourcesImport_InvalidKind(t *testing.T) {
}
}

// mockHarnessConfigTarball installs a mock HTTP transport that serves a gzip
// tarball containing a single harness-config directory, and returns a cleanup
// func. It must not be used with t.Parallel().
func mockHarnessConfigTarball(t *testing.T) func() {
t.Helper()
old := http.DefaultClient.Transport
http.DefaultClient.Transport = &mockRoundTripper{
roundTrip: func(req *http.Request) (*http.Response, error) {
var buf bytes.Buffer
gzw := gzip.NewWriter(&buf)
tw := tar.NewWriter(gzw)
files := map[string]string{
"repo-main/harness-configs/my-config/config.yaml": "harness: claude\n",
"repo-main/harness-configs/my-config/README.md": "hello",
}
for name, body := range files {
if err := tw.WriteHeader(&tar.Header{Name: name, Mode: 0600, Size: int64(len(body))}); err != nil {
return nil, err
}
if _, err := tw.Write([]byte(body)); err != nil {
return nil, err
}
}
if err := tw.Close(); err != nil {
return nil, err
}
if err := gzw.Close(); err != nil {
return nil, err
}
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewReader(buf.Bytes()))}, nil
},
}
return func() { http.DefaultClient.Transport = old }
}

// mockSingleHarnessConfigTarball serves a tarball where the pointed-to path IS
// the harness-config (leaf), not a parent of configs.
func mockSingleHarnessConfigTarball(t *testing.T) func() {
t.Helper()
old := http.DefaultClient.Transport
http.DefaultClient.Transport = &mockRoundTripper{
roundTrip: func(req *http.Request) (*http.Response, error) {
var buf bytes.Buffer
gzw := gzip.NewWriter(&buf)
tw := tar.NewWriter(gzw)
files := map[string]string{
"repo-main/harnesses/antigravity/config.yaml": "harness: claude\n",
"repo-main/harnesses/antigravity/README.md": "hello",
}
for name, body := range files {
if err := tw.WriteHeader(&tar.Header{Name: name, Mode: 0600, Size: int64(len(body))}); err != nil {
return nil, err
}
if _, err := tw.Write([]byte(body)); err != nil {
return nil, err
}
}
if err := tw.Close(); err != nil {
return nil, err
}
if err := gzw.Close(); err != nil {
return nil, err
}
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewReader(buf.Bytes()))}, nil
},
}
return func() { http.DefaultClient.Transport = old }
}

// TestHandleResourcesImport_HarnessConfigGlobal verifies importing harness-configs
// via the unified endpoint with global scope.
func TestHandleResourcesImport_HarnessConfigGlobal(t *testing.T) {
srv, s, _ := testTemplateBootstrapServer(t)
ctx := context.Background()

admin := &store.User{ID: tid("user-admin-hc"), Email: "admin-hc@test.com", DisplayName: "Admin", Role: store.UserRoleAdmin}
if err := s.CreateUser(ctx, admin); err != nil {
t.Fatal(err)
}
ensureHubMembership(ctx, s, admin.ID)

defer mockHarnessConfigTarball(t)()

rec := doRequestAsUser(t, srv, admin, http.MethodPost, "/api/v1/resources/import", ImportResourcesRequest{
Kind: "harness-config",
Scope: "global",
SourceURL: "https://github.com/acme/repo/tree/main/harness-configs",
})
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}

var resp ImportResourcesResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
if resp.Count != 1 || len(resp.Imported) != 1 || resp.Imported[0] != "my-config" {
t.Fatalf("expected [my-config], got %+v", resp)
}

result, err := s.ListHarnessConfigs(ctx, store.HarnessConfigFilter{Scope: store.HarnessConfigScopeGlobal}, store.ListOptions{Limit: 10})
if err != nil {
t.Fatal(err)
}
if result.TotalCount != 1 {
t.Fatalf("expected 1 global harness-config, got %d", result.TotalCount)
}
if result.Items[0].Scope != store.HarnessConfigScopeGlobal {
t.Errorf("expected global scope, got %q", result.Items[0].Scope)
}
}

// TestHandleResourcesImport_SingleHarnessConfig verifies importing a single
// harness-config directory (not a directory-of-directories) works correctly.
func TestHandleResourcesImport_SingleHarnessConfig(t *testing.T) {
srv, s, _ := testTemplateBootstrapServer(t)
ctx := context.Background()

admin := &store.User{ID: tid("user-admin-single-hc"), Email: "admin-single-hc@test.com", DisplayName: "Admin", Role: store.UserRoleAdmin}
if err := s.CreateUser(ctx, admin); err != nil {
t.Fatal(err)
}
ensureHubMembership(ctx, s, admin.ID)

defer mockSingleHarnessConfigTarball(t)()

rec := doRequestAsUser(t, srv, admin, http.MethodPost, "/api/v1/resources/import", ImportResourcesRequest{
Kind: "harness-config",
Scope: "global",
SourceURL: "https://github.com/acme/repo/tree/main/harnesses/antigravity",
})
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}

var resp ImportResourcesResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
if resp.Count != 1 || len(resp.Imported) != 1 || resp.Imported[0] != "antigravity" {
t.Fatalf("expected [antigravity], got %+v", resp)
}

result, err := s.ListHarnessConfigs(ctx, store.HarnessConfigFilter{Scope: store.HarnessConfigScopeGlobal}, store.ListOptions{Limit: 10})
if err != nil {
t.Fatal(err)
}
if result.TotalCount != 1 {
t.Fatalf("expected 1 global harness-config, got %d", result.TotalCount)
}
}

// TestHandleProjectImportHarnessConfigs verifies the per-project endpoint
// POST /api/v1/projects/{id}/import-harness-configs works for remote URLs.
func TestHandleProjectImportHarnessConfigs(t *testing.T) {
srv, s, project, _ := setupWorkspaceProject(t, "hc-proj-import")
ctx := context.Background()

admin := &store.User{ID: tid("user-admin-proj-hc"), Email: "admin-proj-hc@test.com", DisplayName: "Admin", Role: store.UserRoleAdmin}
if err := s.CreateUser(ctx, admin); err != nil {
t.Fatal(err)
}
ensureHubMembership(ctx, s, admin.ID)

defer mockHarnessConfigTarball(t)()

rec := doRequestAsUser(t, srv, admin, http.MethodPost,
"/api/v1/projects/"+project.ID+"/import-harness-configs",
ImportHarnessConfigsRequest{
SourceURL: "https://github.com/acme/repo/tree/main/harness-configs",
})
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}

var resp ImportHarnessConfigsResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
if resp.Count != 1 || len(resp.HarnessConfigs) != 1 || resp.HarnessConfigs[0] != "my-config" {
t.Fatalf("expected [my-config], got %+v", resp)
}

result, err := s.ListHarnessConfigs(ctx, store.HarnessConfigFilter{
Scope: store.HarnessConfigScopeProject,
ProjectID: project.ID,
}, store.ListOptions{Limit: 10})
if err != nil {
t.Fatal(err)
}
if result.TotalCount != 1 {
t.Fatalf("expected 1 project-scoped harness-config, got %d", result.TotalCount)
}
}

// TestHandleResourcesImport_MissingSourceURL verifies sourceUrl is required.
func TestHandleResourcesImport_MissingSourceURL(t *testing.T) {
srv, s, _ := testTemplateBootstrapServer(t)
Expand Down
3 changes: 3 additions & 0 deletions pkg/hub/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -2532,6 +2532,9 @@ func (s *Server) registerRoutes() {
s.mux.HandleFunc("/api/v1/discord/link/verify", s.handleDiscordLinkVerify)
s.mux.HandleFunc("/api/v1/discord/link/status", s.handleDiscordLinkStatus)

// Unified resource import endpoint (templates + harness-configs, global + project)
s.mux.HandleFunc("/api/v1/resources/import", s.handleResourcesImport)

// GitHub App webhook and setup callback (unauthenticated — uses webhook signature)
s.mux.HandleFunc("/api/v1/webhooks/github", s.handleGitHubWebhook)
s.mux.HandleFunc("/github-app/setup", s.handleGitHubAppSetup)
Expand Down
Loading