diff --git a/pkg/hub/handlers.go b/pkg/hub/handlers.go index 39ff168a5..9bb0f4b80 100644 --- a/pkg/hub/handlers.go +++ b/pkg/hub/handlers.go @@ -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") @@ -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. diff --git a/pkg/hub/resource_import_handler_test.go b/pkg/hub/resource_import_handler_test.go index 98c521d78..31246ed82 100644 --- a/pkg/hub/resource_import_handler_test.go +++ b/pkg/hub/resource_import_handler_test.go @@ -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) diff --git a/pkg/hub/server.go b/pkg/hub/server.go index 91fb9802b..a5b82ab97 100644 --- a/pkg/hub/server.go +++ b/pkg/hub/server.go @@ -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)