diff --git a/.fabrica.yaml b/.fabrica.yaml new file mode 100644 index 0000000..3a3efb8 --- /dev/null +++ b/.fabrica.yaml @@ -0,0 +1,45 @@ +# SPDX-FileCopyrightText: © 2026 OpenCHAMI a Series of LF Projects, LLC +# SPDX-License-Identifier: MIT + +project: + name: testbed + module: github.com/openchami/github-actions-testbed + description: Fabrica testbed for the OpenCHAMI github-actions workflows + created: 2026-10-20T09:31:45.79078442-06:00 +features: + validation: + enabled: true + mode: strict + events: + enabled: false + bus_type: memory + conditional: + enabled: true + etag_algorithm: sha256 + auth: + enabled: false + storage: + enabled: true + type: file + db_driver: sqlite3 + metrics: + enabled: false + provider: prometheus + reconciliation: + enabled: false + worker_count: 5 + requeue_delay: 5 + security: + authn: + enabled: false + authz: + enabled: false + mode: enforce +generation: + handlers: true + storage: true + client: true + openapi: true + events: false + middleware: true + reconciliation: false diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 52e3cc8..0e9e4fc 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -32,9 +32,14 @@ jobs: lint-makefile-common: uses: OpenCHAMI/github-actions/.github/workflows/lint-makefile-common.yml@5a0ca6e79ffd9f8db2b517beb176039628d22095 # github-actions under test (seantronsen/feat/release-attestation) + lint-codegen-fabrica: + uses: OpenCHAMI/github-actions/.github/workflows/lint-codegen-fabrica.yml@ce924a777ca3efdd577e4e81d2819a1f14260935 # github-actions under test (seantronsen/feat/release-attestation) + with: + go-version-file: go.mod + lint: if: ${{ always() }} - needs: [lint-go, lint-ci, lint-renovate, lint-makefile-common] + needs: [lint-go, lint-ci, lint-renovate, lint-makefile-common, lint-codegen-fabrica] runs-on: ubuntu-slim steps: - uses: re-actors/alls-green@b5b5b37504aa4183270bd3d855c52a67f212be35 # v1.3.0 diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 38be531..4dd75c7 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -17,18 +17,19 @@ before: builds: - id: testbed - main: ./cmd/testbed + main: ./cmd/server binary: testbed goos: [linux] goarch: [amd64, arm64] env: - CGO_ENABLED=0 + # Version variables declared by Fabrica's generated version_generated.go. ldflags: - >- -s -w - -X main.version={{ .Version }} - -X main.commit={{ .Commit }} - -X main.buildHost={{ .Env.BUILD_HOST }} + -X main.serviceVersion={{ .Version }} + -X main.serviceCommit={{ .Commit }} + -X main.serviceDate={{ .Date }} archives: - id: testbed diff --git a/Dockerfile b/Dockerfile index 5062711..5da2876 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,3 +6,4 @@ FROM gcr.io/distroless/static ARG TARGETPLATFORM COPY $TARGETPLATFORM/testbed /usr/bin/testbed ENTRYPOINT ["/usr/bin/testbed"] +CMD ["serve"] diff --git a/Makefile b/Makefile index 097fb9c..1d682e9 100644 --- a/Makefile +++ b/Makefile @@ -4,6 +4,8 @@ IMAGE_TAG ?= $(shell git describe --tags --always --abbrev=0) QUADLET_DIR ?= build/quadlet CLEANFILES := bin/ build/ dist/ +# cmd/client is Fabrica's generated CLI; only the server ships. +BINARIES := testbed:./cmd/server include Makefile.common diff --git a/README.md b/README.md index 077b777..247cd49 100644 --- a/README.md +++ b/README.md @@ -37,18 +37,25 @@ image (`dockers_v2`), rpm/deb quadlet packages, attestations, and the GitHub release, plus release signing (`checksums.txt`, through the repo's real key chain, like any other OpenCHAMI repo). +It's also a minimal [Fabrica](https://github.com/OpenCHAMI/fabrica) project +(one `Widget` resource, file storage), so `lint-codegen-fabrica` has generated +code to check. Edit `apis/` or `.fabrica.yaml`, run `make generate`, and commit +the result; `make generate-check` is what CI runs. + ## Using this as a template 1. Copy `.github/workflows/`, `.goreleaser.yaml`, `Dockerfile`, `Makefile`'s `quadlet-render` target, and `packaging/`. 2. Rename `testbed` / `github-actions-testbed` to your project (binary, image, package name, quadlet, URLs, badges). -3. Update the expected package file lists in `build.yml` and `release.yml` +3. For a Fabrica project, keep the `lint-codegen-fabrica` job in `lint.yml`; + otherwise drop it. +4. Update the expected package file lists in `build.yml` and `release.yml` (`validate-packages`): rpm lists every file and owned directory, deb only files. -4. Pin `OpenCHAMI/github-actions` to a release (or SHA) instead of the testbed's +5. Pin `OpenCHAMI/github-actions` to a release (or SHA) instead of the testbed's branch pin. -5. Provision your repo's key as the `GPG_REPO_CERT_KEY_B64` secret; `sign` +6. Provision your repo's key as the `GPG_REPO_CERT_KEY_B64` secret; `sign` also reads the `MASTER_PUBLIC_ASC` / `MASTER_FPR` org secrets. Annotated GoReleaser reference: [`.goreleaser.example.yml`](https://github.com/OpenCHAMI/github-actions/blob/main/.goreleaser.example.yml). diff --git a/apis.yaml b/apis.yaml new file mode 100644 index 0000000..ddc03cc --- /dev/null +++ b/apis.yaml @@ -0,0 +1,10 @@ +# SPDX-FileCopyrightText: © 2026 OpenCHAMI a Series of LF Projects, LLC +# SPDX-License-Identifier: MIT + +groups: + - name: testbed.openchami.io + storageVersion: v1 + versions: + - v1 + resources: + - Widget diff --git a/apis/testbed.openchami.io/v1/widget_types.go b/apis/testbed.openchami.io/v1/widget_types.go new file mode 100644 index 0000000..ed196ab --- /dev/null +++ b/apis/testbed.openchami.io/v1/widget_types.go @@ -0,0 +1,62 @@ +// Copyright © 2026 OpenCHAMI a Series of LF Projects, LLC +// +// SPDX-License-Identifier: MIT + +package v1 + +import ( + "context" + "github.com/openchami/fabrica/pkg/fabrica" +) + +// Widget represents a widget resource +type Widget struct { + APIVersion string `json:"apiVersion" yaml:"apiVersion"` + Kind string `json:"kind" yaml:"kind"` + Metadata fabrica.Metadata `json:"metadata" yaml:"metadata"` + Spec WidgetSpec `json:"spec" yaml:"spec" validate:"required"` + Status WidgetStatus `json:"status,omitempty" yaml:"status,omitempty"` +} + +// WidgetSpec defines the desired state of Widget +type WidgetSpec struct { + Description string `json:"description,omitempty" yaml:"description,omitempty" validate:"max=200"` + // Add your spec fields here +} + +// WidgetStatus defines the observed state of Widget +type WidgetStatus struct { + Phase string `json:"phase,omitempty" yaml:"phase,omitempty"` + Message string `json:"message,omitempty" yaml:"message,omitempty"` + Ready bool `json:"ready" yaml:"ready"` + // Add your status fields here +} + +// Validate implements custom validation logic for Widget +func (r *Widget) Validate(ctx context.Context) error { + // Add custom validation logic here + // Example: + // if r.Spec.Description == "forbidden" { + // return errors.New("description 'forbidden' is not allowed") + // } + + return nil +} + +// GetKind returns the kind of the resource +func (r *Widget) GetKind() string { + return "Widget" +} + +// GetName returns the name of the resource +func (r *Widget) GetName() string { + return r.Metadata.Name +} + +// GetUID returns the UID of the resource +func (r *Widget) GetUID() string { + return r.Metadata.UID +} + +// IsHub marks this as the hub/storage version +func (r *Widget) IsHub() {} diff --git a/apis/testbed.openchami.io/v1/widget_types_test.go b/apis/testbed.openchami.io/v1/widget_types_test.go new file mode 100644 index 0000000..6e39be4 --- /dev/null +++ b/apis/testbed.openchami.io/v1/widget_types_test.go @@ -0,0 +1,27 @@ +// SPDX-FileCopyrightText: © 2026 OpenCHAMI a Series of LF Projects, LLC +// SPDX-License-Identifier: MIT + +package v1 + +import ( + "context" + "testing" + + "github.com/openchami/fabrica/pkg/fabrica" +) + +func TestWidget(t *testing.T) { + w := &Widget{Metadata: fabrica.Metadata{Name: "w1", UID: "uid-1"}} + if got := w.GetKind(); got != "Widget" { + t.Errorf("GetKind() = %q, want %q", got, "Widget") + } + if got := w.GetName(); got != "w1" { + t.Errorf("GetName() = %q, want %q", got, "w1") + } + if got := w.GetUID(); got != "uid-1" { + t.Errorf("GetUID() = %q, want %q", got, "uid-1") + } + if err := w.Validate(context.Background()); err != nil { + t.Errorf("Validate() = %v, want nil", err) + } +} diff --git a/cmd/client/main.go b/cmd/client/main.go new file mode 100644 index 0000000..fcb15a6 --- /dev/null +++ b/cmd/client/main.go @@ -0,0 +1,613 @@ +// Code generated by Fabrica v0.4.11 (commit: aa38d7c). DO NOT EDIT. +// Copyright © 2026 OpenCHAMI a Series of LF Projects, LLC +// +// SPDX-License-Identifier: MIT +// +// This file provides a Cobra-based CLI for the github_actions_testbed API. +// Generated from: pkg/codegen/templates/client-cmd.go.tmpl +// +// To modify the CLI: +// 1. Edit the template file: pkg/codegen/templates/client-cmd.go.tmpl +// 2. Run 'make generate-client-cmd' to regenerate +// 3. Do NOT edit this file directly - changes will be lost +// +// Generated commands for each resource: +// - client health +// - client widget [list|get|create|update|patch|delete] +// +// Global flags (available for all commands): +// +// --server Server URL (env: GITHUB_ACTIONS_TESTBED_SERVER) +// --timeout Request timeout (env: GITHUB_ACTIONS_TESTBED_TIMEOUT) +// --output, -o Output format: table, json, yaml (env: GITHUB_ACTIONS_TESTBED_OUTPUT) +// --version, -v API version to request: v1, v2beta1, etc. (env: GITHUB_ACTIONS_TESTBED_VERSION) +// --token JWT bearer token (env: GITHUB_ACTIONS_TESTBED_TOKEN) +// --show-token Show the full bearer token in debug logs +// --config Config file path (default: ~/.github_actions_testbed-cli.yaml) +// +// Configuration sources (in order of precedence): +// 1. Command-line flags +// 2. Environment variables (GITHUB_ACTIONS_TESTBED_*) +// 3. Config file (~/.github_actions_testbed-cli.yaml) +// 4. Default values +// +// Usage examples: +// +// # List all widgets (default version) +// client widget list +// +// # List widgets with specific version +// client widget list --version v2beta1 +// +// # Get Widget as v1 +// client widget get --version v1 +// +// # Create from JSON file with version +// cat widget.json | client widget create --version v2beta1 +// +// # Create from inline JSON +// client widget create --spec '{"name":"widget-01","description":"Example Widget"}' +// +// # Use environment variables +// export GITHUB_ACTIONS_TESTBED_SERVER=https://github_actions_testbed.example.com +// export GITHUB_ACTIONS_TESTBED_VERSION=v2beta1 +// client widget list +// +// To add custom commands: +// 1. Add command definition in template after resource commands +// 2. Register it in init() function +// +// To change output formatting: +// 1. Modify printOutput function to add new formats +// 2. Update output flag validation +// +// To add authentication: +// 1. Add auth flags (--token, --username, etc.) +// 2. Modify getClient to configure auth in http.Client +// 3. Add auth headers in client package +package main + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/openchami/github-actions-testbed/pkg/client" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +var ( + cfgFile string + serverURL string + timeout time.Duration + output string + apiVersion string + bearerToken string + showToken bool + logLevel client.LogLevel +) + +func main() { + if err := rootCmd.Execute(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } +} + +var rootCmd = &cobra.Command{ + Use: filepath.Base(os.Args[0]), + Short: "github_actions_testbed CLI", + Long: `A command-line interface for managing github_actions_testbed resources.`, +} + +func init() { + cobra.OnInitialize(initConfig) + + // Global flags + rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.github_actions_testbed-cli.yaml)") + rootCmd.PersistentFlags().StringVar(&serverURL, "server", "http://localhost:8080", "github_actions_testbed server URL") + rootCmd.PersistentFlags().DurationVar(&timeout, "timeout", 30*time.Second, "request timeout") + rootCmd.PersistentFlags().StringVarP(&output, "output", "o", "table", "output format: table, json, yaml") + rootCmd.PersistentFlags().StringVarP(&apiVersion, "version", "v", "", "API version to request (e.g., v1, v2beta1)") + rootCmd.PersistentFlags().StringVar(&bearerToken, "token", "", "JWT bearer token") + rootCmd.PersistentFlags().BoolVar(&showToken, "show-token", false, "show full bearer token in debug logs instead of a truncated value") + rootCmd.PersistentFlags().VarP(&logLevel, "log-level", "l", "set verbosity of logs (e.g., info, warning, debug)") + + // Register shell completion functions + rootCmd.RegisterFlagCompletionFunc("log-level", client.CompletionLogLevel) + + // Bind flags to viper + viper.BindPFlag("server", rootCmd.PersistentFlags().Lookup("server")) + viper.BindPFlag("timeout", rootCmd.PersistentFlags().Lookup("timeout")) + viper.BindPFlag("output", rootCmd.PersistentFlags().Lookup("output")) + viper.BindPFlag("version", rootCmd.PersistentFlags().Lookup("version")) + viper.BindPFlag("token", rootCmd.PersistentFlags().Lookup("token")) + viper.BindPFlag("show-token", rootCmd.PersistentFlags().Lookup("show-token")) + viper.BindPFlag("log-level", rootCmd.PersistentFlags().Lookup("log-level")) + + // Environment variable support + viper.SetEnvPrefix("GITHUB_ACTIONS_TESTBED") + viper.AutomaticEnv() + + // Add resource commands + rootCmd.AddCommand(healthCmd) + rootCmd.AddCommand(NewVersionCommand()) + rootCmd.AddCommand(widgetCmd) + +} + +func initConfig() { + if cfgFile != "" { + viper.SetConfigFile(cfgFile) + } else { + home, err := os.UserHomeDir() + if err != nil { + fmt.Fprintf(os.Stderr, "Warning: %v\n", err) + return + } + + viper.AddConfigPath(home) + viper.SetConfigType("yaml") + viper.SetConfigName(".github_actions_testbed-cli") + } + + if err := viper.ReadInConfig(); err == nil { + fmt.Fprintln(os.Stderr, "Using config file:", viper.ConfigFileUsed()) + } +} + +func getClient() (*client.Client, error) { + serverURL := viper.GetString("server") + // Error will cause default logger, which is handled by NewClient() + logger, _ := client.NewLogger(logLevel) + c, err := client.NewClient(serverURL, nil, logger) + if err != nil { + return nil, err + } + c = c.WithShowToken(viper.GetBool("show-token")) + + // Apply version if specified + version := viper.GetString("version") + if version != "" { + c = c.WithVersion(version) + } + + token := viper.GetString("token") + if token != "" { + c = c.WithBearerToken(token) + } + + return c, nil +} + +func printOutput(data interface{}) error { + switch output { + case "json": + encoder := json.NewEncoder(os.Stdout) + encoder.SetIndent("", " ") + return encoder.Encode(data) + case "yaml": + // TODO: Add YAML support if needed + return fmt.Errorf("YAML output not yet implemented") + case "table": + // Simple table output + encoder := json.NewEncoder(os.Stdout) + encoder.SetIndent("", " ") + return encoder.Encode(data) + default: + return fmt.Errorf("unknown output format: %s", output) + } +} + +var healthCmd = &cobra.Command{ + Use: "health", + Short: "Get service health status", + RunE: func(cmd *cobra.Command, args []string) error { + c, err := getClient() + if err != nil { + return fmt.Errorf("failed to create client: %w", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + health, err := c.GetHealth(ctx) + if err != nil { + return fmt.Errorf("failed to get health status: %w", err) + } + + return printOutput(health) + }, +} + +// setNestedField sets a field in a nested map using dot notation +// Example: setNestedField(map, "status.health", "OK") sets map["status"]["health"] = "OK" +func setNestedField(target map[string]interface{}, path string, value interface{}) { + parts := strings.Split(path, ".") + current := target + + // Navigate to the parent of the target field + for _, part := range parts[:len(parts)-1] { + if current[part] == nil { + current[part] = make(map[string]interface{}) + } + if nested, ok := current[part].(map[string]interface{}); ok { + current = nested + } else { + // Convert to map if it's not already + current[part] = make(map[string]interface{}) + current = current[part].(map[string]interface{}) + } + } + + // Set the final field + finalField := parts[len(parts)-1] + if value == nil { + // For unset operations, we use JSON Merge Patch null semantics + current[finalField] = nil + } else if stringValue, ok := value.(string); ok { + // Try to parse as JSON first, then as string + var jsonValue interface{} + if err := json.Unmarshal([]byte(stringValue), &jsonValue); err == nil { + current[finalField] = jsonValue + } else { + current[finalField] = stringValue + } + } else { + current[finalField] = value + } +} + +// Widget commands +var widgetCmd = &cobra.Command{ + Use: "widget", + Short: "Manage widgets", + Long: `Create, read, update, patch, and delete widgets.`, +} + +var widgetListCmd = &cobra.Command{ + Use: "list", + Short: "List all widgets", + RunE: func(cmd *cobra.Command, args []string) error { + c, err := getClient() + if err != nil { + return fmt.Errorf("failed to create client: %w", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + items, err := c.GetWidgets(ctx) + if err != nil { + return fmt.Errorf("failed to list widgets: %w", err) + } + + return printOutput(items) + }, +} + +var widgetGetCmd = &cobra.Command{ + Use: "get [uid]", + Short: "Get a Widget by UID", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := getClient() + if err != nil { + return fmt.Errorf("failed to create client: %w", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + item, err := c.GetWidget(ctx, args[0]) + if err != nil { + return fmt.Errorf("failed to get Widget: %w", err) + } + + return printOutput(item) + }, +} + +var widgetCreateCmd = &cobra.Command{ + Use: "create", + Short: "Create a new Widget", + Long: `Create a new Widget. + +Examples: + # Create from stdin + echo '{"description": "Example description"}' | client widget create + + # Create with --spec flag + client widget create --spec '{"description": "Example description"}' + +Spec fields: + description (string) +`, + RunE: func(cmd *cobra.Command, args []string) error { + c, err := getClient() + if err != nil { + return fmt.Errorf("failed to create client: %w", err) + } + + // Read request from flags or stdin + reqJSON, _ := cmd.Flags().GetString("spec") + var req client.CreateWidgetRequest + + if reqJSON == "" { + // Read from stdin if no spec provided + decoder := json.NewDecoder(os.Stdin) + if err := decoder.Decode(&req); err != nil { + return fmt.Errorf("failed to decode request from stdin: %w", err) + } + } else { + // Parse request from JSON string + if err := json.Unmarshal([]byte(reqJSON), &req); err != nil { + return fmt.Errorf("failed to parse request JSON: %w", err) + } + } + + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + item, err := c.CreateWidget(ctx, req) + if err != nil { + return fmt.Errorf("failed to create Widget: %w", err) + } + + return printOutput(item) + }, +} + +var widgetUpdateCmd = &cobra.Command{ + Use: "update [uid]", + Short: "Update an existing Widget", + Long: `Update an existing Widget. + +Examples: + # Update from stdin + echo '{"description": "Example description"}' | client widget update + + # Update with --spec flag + client widget update --spec '{"description": "Example description"}' + +Spec fields: + description (string) +`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := getClient() + if err != nil { + return fmt.Errorf("failed to create client: %w", err) + } + + // Read request from flags or stdin + reqJSON, _ := cmd.Flags().GetString("spec") + var req client.UpdateWidgetRequest + + if reqJSON == "" { + // Read from stdin if no spec provided + decoder := json.NewDecoder(os.Stdin) + if err := decoder.Decode(&req); err != nil { + return fmt.Errorf("failed to decode request from stdin: %w", err) + } + } else { + // Parse request from JSON string + if err := json.Unmarshal([]byte(reqJSON), &req); err != nil { + return fmt.Errorf("failed to parse request JSON: %w", err) + } + } + + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + item, err := c.UpdateWidget(ctx, args[0], req) + if err != nil { + return fmt.Errorf("failed to update Widget: %w", err) + } + + return printOutput(item) + }, +} + +var widgetPatchCmd = &cobra.Command{ + Use: "patch [uid]", + Short: "Patch a Widget", + Long: `Patch an existing Widget spec using various patch formats. + +IMPORTANT: Only the spec portion of the resource can be patched. +Metadata (name, labels, annotations) and status are managed by the API. + +Examples: + # JSON Merge Patch (simple merge) - patch spec fields + client widget patch --spec '{"manufacturer":"Intel","model":"Updated Model"}' + + # Shorthand patch (dot notation - most convenient) + client widget patch --set manufacturer=Intel --set model="Updated Model" --unset customField + + # JSON Patch (RFC 6902 - most powerful) + client widget patch --json-patch '[ + {"op":"replace","path":"/manufacturer","value":"Intel"}, + {"op":"add","path":"/properties/newField","value":"newValue"} + ]' + + # From stdin (JSON Merge Patch format) + echo '{"manufacturer":"AMD","partNumber":"RYZEN-9000"}' | client widget patch + +Patch Formats: + --spec JSON Merge Patch (RFC 7386) - simple object merge + --set/--unset Shorthand patch - dot notation for convenience + --json-patch JSON Patch (RFC 6902) - operation-based patches + stdin JSON Merge Patch format + +Shorthand Operations (spec fields only): + --set field=value Set a spec field value (supports dot notation) + --unset field Remove a spec field (supports dot notation) + --add field=value Add to spec array field (field must end with '.-') + --remove field=value Remove from spec array field + +Note: All patch operations target the resource spec only. +Attempts to patch metadata or status fields will be ignored.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := getClient() + if err != nil { + return fmt.Errorf("failed to create client: %w", err) + } + + uid := args[0] + + // Get patch flags + specPatch, _ := cmd.Flags().GetString("spec") + jsonPatch, _ := cmd.Flags().GetString("json-patch") + setPairs, _ := cmd.Flags().GetStringArray("set") + unsetFields, _ := cmd.Flags().GetStringArray("unset") + addPairs, _ := cmd.Flags().GetStringArray("add") + removePairs, _ := cmd.Flags().GetStringArray("remove") + + var patchData []byte + var contentType string + + // Determine patch format and build patch data + if jsonPatch != "" { + // JSON Patch (RFC 6902) + patchData = []byte(jsonPatch) + contentType = "application/json-patch+json" + } else if len(setPairs) > 0 || len(unsetFields) > 0 || len(addPairs) > 0 || len(removePairs) > 0 { + // Shorthand patch - convert to JSON Merge Patch + patch := make(map[string]interface{}) + + // Process --set flags + for _, setPair := range setPairs { + parts := strings.SplitN(setPair, "=", 2) + if len(parts) != 2 { + return fmt.Errorf("invalid --set format: %s (expected field=value)", setPair) + } + setNestedField(patch, parts[0], parts[1]) + } + + // Process --unset flags + for _, field := range unsetFields { + setNestedField(patch, field, nil) + } + + // Process --add flags (add to arrays) + for _, addPair := range addPairs { + parts := strings.SplitN(addPair, "=", 2) + if len(parts) != 2 { + return fmt.Errorf("invalid --add format: %s (expected field=value)", addPair) + } + // For arrays, we'll use JSON Merge Patch append syntax if possible + // Otherwise convert to JSON Patch + setNestedField(patch, parts[0], parts[1]) + } + + // Process --remove flags + for _, removePair := range removePairs { + parts := strings.SplitN(removePair, "=", 2) + if len(parts) != 2 { + return fmt.Errorf("invalid --remove format: %s (expected field=value)", removePair) + } + // Remove operations are complex and might need JSON Patch + // For now, we'll handle simple cases + return fmt.Errorf("--remove operations require --json-patch format") + } + + patchBytes, err := json.Marshal(patch) + if err != nil { + return fmt.Errorf("failed to marshal shorthand patch: %w", err) + } + patchData = patchBytes + contentType = "application/merge-patch+json" + } else if specPatch != "" { + // JSON Merge Patch from --spec + patchData = []byte(specPatch) + contentType = "application/merge-patch+json" + } else { + // Read from stdin (default to JSON Merge Patch) + decoder := json.NewDecoder(os.Stdin) + var patch interface{} + if err := decoder.Decode(&patch); err != nil { + return fmt.Errorf("failed to decode patch from stdin: %w", err) + } + patchBytes, err := json.Marshal(patch) + if err != nil { + return fmt.Errorf("failed to marshal patch: %w", err) + } + patchData = patchBytes + contentType = "application/merge-patch+json" + } + + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + item, err := c.PatchWidget(ctx, uid, patchData, contentType) + if err != nil { + return fmt.Errorf("failed to patch Widget: %w", err) + } + + return printOutput(item) + }, +} + +var widgetDeleteCmd = &cobra.Command{ + Use: "delete [uid...]", + Short: "Delete one or more Widget resources", + Long: `Deletes one or more Widget resources. + +You can provide multiple UIDs separated by spaces to delete them all in a single command. +Each UID is processed one by one as an individual, atomic delete operation. If any deletion fails, the command will still attempt to delete the remaining UIDs and report the errors at the end.`, + Args: cobra.MinimumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := getClient() + if err != nil { + return fmt.Errorf("failed to create client: %w", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + var errs []error + for _, uid := range args { + if err := c.DeleteWidget(ctx, uid); err != nil { + errs = append(errs, fmt.Errorf("failed to delete Widget %q: %w", uid, err)) + continue + } + fmt.Printf("Widget %s deleted successfully\n", uid) + } + + if len(errs) > 0 { + var errMsgs []string + for _, e := range errs { + errMsgs = append(errMsgs, e.Error()) + } + return fmt.Errorf("encountered %d error(s) during deletion:\n%s", len(errs), strings.Join(errMsgs, "\n")) + } + return nil + }, +} + +func init() { + widgetCmd.AddCommand(widgetListCmd) + widgetCmd.AddCommand(widgetGetCmd) + widgetCmd.AddCommand(widgetCreateCmd) + widgetCmd.AddCommand(widgetUpdateCmd) + widgetCmd.AddCommand(widgetPatchCmd) + widgetCmd.AddCommand(widgetDeleteCmd) + + // Add spec flag for create and update commands + widgetCreateCmd.Flags().String("spec", "", "Widget specification in JSON format") + widgetUpdateCmd.Flags().String("spec", "", "Widget specification in JSON format") + + // Add patch command flags + widgetPatchCmd.Flags().String("spec", "", "JSON Merge Patch specification") + widgetPatchCmd.Flags().String("json-patch", "", "JSON Patch operations (RFC 6902)") + widgetPatchCmd.Flags().StringArray("set", nil, "Set field value using dot notation (field=value)") + widgetPatchCmd.Flags().StringArray("unset", nil, "Unset field using dot notation") + widgetPatchCmd.Flags().StringArray("add", nil, "Add value to array field (field=value)") + widgetPatchCmd.Flags().StringArray("remove", nil, "Remove value from array field (field=value)") +} diff --git a/cmd/client/version_generated.go b/cmd/client/version_generated.go new file mode 100644 index 0000000..f284c84 --- /dev/null +++ b/cmd/client/version_generated.go @@ -0,0 +1,57 @@ +// Code generated by Fabrica v0.4.11 (commit: aa38d7c). DO NOT EDIT. +// Template: client/version.go.tmpl +// Generated: 2026-10-20T15:31:55Z +// +// SPDX-FileCopyrightText: Copyright © 2026 OpenCHAMI a Series of LF Projects, LLC +// +// SPDX-License-Identifier: MIT + +package main + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +// Client version information - set via ldflags at build time +var ( + clientVersion = "dev" + clientCommit = "none" + clientDate = "unknown" +) + +// fabricaVersion is embedded at generation time +const fabricaVersion = "v0.4.11" + +func clientVersionString() string { + if clientCommit == "none" && clientDate == "unknown" { + return clientVersion + } + if clientCommit == "none" { + return fmt.Sprintf("%s (built: %s)", clientVersion, clientDate) + } + if clientDate == "unknown" { + return fmt.Sprintf("%s (commit: %s)", clientVersion, clientCommit) + } + return fmt.Sprintf("%s (commit: %s, built: %s)", clientVersion, clientCommit, clientDate) +} + +// NewVersionCommand creates the version command for the CLI client +func NewVersionCommand() *cobra.Command { + return &cobra.Command{ + Use: "version", + Short: "Print version information", + Long: `Print version information for github_actions_testbed CLI client`, + Run: func(cmd *cobra.Command, args []string) { + fmt.Printf("github_actions_testbed CLI version %s\n", clientVersion) + if clientCommit != "none" { + fmt.Printf(" commit: %s\n", clientCommit) + } + if clientDate != "unknown" { + fmt.Printf(" built: %s\n", clientDate) + } + fmt.Printf(" generated by: Fabrica %s\n", fabricaVersion) + }, + } +} diff --git a/cmd/server/auth_helpers_generated.go b/cmd/server/auth_helpers_generated.go new file mode 100644 index 0000000..9b56dc5 --- /dev/null +++ b/cmd/server/auth_helpers_generated.go @@ -0,0 +1,23 @@ + +// Code generated by Fabrica v0.4.11. DO NOT EDIT. +// Template: init/auth_helpers.go.tmpl +// Generated: 2026-10-20T15:32:26Z +// +// SPDX-FileCopyrightText: Copyright © 2026 OpenCHAMI a Series of LF Projects, LLC +// +// SPDX-License-Identifier: MIT + +package main + +import ( + "net/http" + +) + +func initializeAuthMiddleware(config *Config) (func(http.Handler) http.Handler, func(http.Handler) http.Handler, error) { + + return nil, nil, nil + +} + + diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..c069409 --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,309 @@ + +// Code generated by Fabrica v0.4.11. DO NOT EDIT. +// Template: init/main.go.tmpl +// Generated: 2026-10-20T15:32:26Z +// +// SPDX-FileCopyrightText: Copyright © 2026 OpenCHAMI a Series of LF Projects, LLC +// +// SPDX-License-Identifier: MIT + +package main + +import ( + "context" + "fmt" + "log" + "net/http" + "os" + "os/signal" + "strings" + "syscall" + "time" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" + "github.com/spf13/viper" + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + _ "github.com/openchami/github-actions-testbed/pkg/apiversion" + "github.com/openchami/fabrica/pkg/versioning" +) + + + +// Config holds all configuration for the service +type Config struct { + // Server Configuration + Port int `mapstructure:"port"` + Host string `mapstructure:"host"` + ReadTimeout int `mapstructure:"read_timeout"` + WriteTimeout int `mapstructure:"write_timeout"` + IdleTimeout int `mapstructure:"idle_timeout"` + + + // Storage Configuration + + DataDir string `mapstructure:"data_dir"` + + + + + + + + // Feature Flags + Debug bool `mapstructure:"debug"` +} + +// DefaultConfig returns the default configuration +func DefaultConfig() *Config { + return &Config{ + Port: 8080, + Host: "0.0.0.0", + ReadTimeout: 15, + WriteTimeout: 15, + IdleTimeout: 60, + + + DataDir: "./data", + + + + + Debug: false, + } +} + +var ( + cfgFile string + config *Config +) + +func main() { + if err := rootCmd.Execute(); err != nil { + log.Fatal(err) + } +} + +var rootCmd = &cobra.Command{ + Use: "testbed", + Short: "Fabrica testbed for the OpenCHAMI github-actions workflows", + Long: `testbed - A Fabrica-generated OpenCHAMI service`, + RunE: runServer, +} + +var serveCmd = &cobra.Command{ + Use: "serve", + Short: "Start the testbed server", + Long: `Start the testbed HTTP server with the configured options`, + RunE: runServer, +} + +func bindFlagsWithUnderscoreKeys(flags *pflag.FlagSet) error { + var bindErr error + + flags.VisitAll(func(flag *pflag.Flag) { + if bindErr != nil { + return + } + + key := strings.ReplaceAll(flag.Name, "-", "_") + bindErr = viper.BindPFlag(key, flag) + }) + + return bindErr +} + +func init() { + cobra.OnInitialize(initConfig) + + // Global flags + rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.testbed.yaml)") + rootCmd.PersistentFlags().Bool("debug", false, "Enable debug logging") + + // Server flags + serveCmd.Flags().IntP("port", "p", 8080, "Port to listen on") + serveCmd.Flags().String("host", "0.0.0.0", "Host to bind to") + serveCmd.Flags().Int("read-timeout", 15, "Read timeout in seconds") + serveCmd.Flags().Int("write-timeout", 15, "Write timeout in seconds") + serveCmd.Flags().Int("idle-timeout", 60, "Idle timeout in seconds") + + + + serveCmd.Flags().String("data-dir", "./data", "Directory for file storage") + + + + + + // Bind flags to viper + if err := bindFlagsWithUnderscoreKeys(serveCmd.Flags()); err != nil { + panic(fmt.Errorf("failed to bind serve flags: %w", err)) + } + if err := bindFlagsWithUnderscoreKeys(rootCmd.PersistentFlags()); err != nil { + panic(fmt.Errorf("failed to bind root flags: %w", err)) + } + + // Add subcommands + rootCmd.AddCommand(serveCmd) + + rootCmd.AddCommand(NewVersionCommand()) + + // Generated by Fabrica v0.4.0+: export and import commands (Ent storage only) + +} + +func initConfig() { + config = DefaultConfig() + + if cfgFile != "" { + viper.SetConfigFile(cfgFile) + } else { + // Search for config in home directory + home, err := os.UserHomeDir() + cobra.CheckErr(err) + + viper.AddConfigPath(home) + viper.AddConfigPath(".") + viper.SetConfigType("yaml") + viper.SetConfigName(".testbed") + } + + // Environment variables + viper.SetEnvPrefix("TESTBED") + viper.SetEnvKeyReplacer(strings.NewReplacer("-", "_")) + viper.AutomaticEnv() + + // Read config file if it exists + if err := viper.ReadInConfig(); err == nil { + log.Printf("Using config file: %s", viper.ConfigFileUsed()) + } + + // Unmarshal config + if err := viper.Unmarshal(config); err != nil { + log.Fatalf("Unable to decode into config struct: %v", err) + } + + // Set debug logging + if config.Debug { + log.SetFlags(log.LstdFlags | log.Lshortfile) + log.Println("Debug logging enabled") + } +} + +func runServer(cmd *cobra.Command, args []string) error { + log.Printf("Starting testbed server...") + + storageCleanup, err := initializeStorage(config) + if err != nil { + return err + } + defer storageCleanup() + + runtimeCleanup, err := initializeEventingAndReconciliation(config) + if err != nil { + return err + } + defer runtimeCleanup() + + + + // Register resource prefixes for UID generation + // This is required before any handlers can create resources + if err := registerResourcePrefixes(); err != nil { + return fmt.Errorf("failed to register resource prefixes: %w", err) + } + + // Setup router + r := chi.NewRouter() + + // Add middleware + r.Use(middleware.Logger) + r.Use(middleware.Recoverer) + r.Use(middleware.RequestID) + r.Use(middleware.RealIP) + + // Initialize metrics (returns nil if disabled in .fabrica.yaml) + metrics := initializeMetrics(config) + if metrics != nil { + r.Use(metrics.Middleware) + } + + // Version negotiation (Accept header `version=` or URL strategy) + // Initialize and use global version registry when versioning is enabled. + // Note: VersionNegotiationMiddleware currently uses versioning.VersionRegistry + // Versioning support will be fully wired in a future update. + r.Use(versioning.VersionNegotiationMiddleware(versioning.GlobalVersionRegistry, nil)) + + if config.Debug { + r.Mount("/debug", middleware.Profiler()) + } + + + + // Public endpoints (bypass AuthN/AuthZ) + r.Group(func(public chi.Router) { + // Service health (always public) + public.Get("/health", healthHandler) + public.Get("/openapi.json", ServeOpenAPISpec) + public.Get("/docs", ServeSwaggerUI) + // Metrics endpoint (available when metrics enabled in .fabrica.yaml) + if metrics != nil { + public.Handle("/metrics", metrics.Handler()) + } + + }) + + // Protected endpoints (API handlers) + r.Group(func(protected chi.Router) { + + + // Register routes - generated by 'fabrica generate' + RegisterGeneratedRoutes(protected) + }) + + // Create HTTP server + addr := fmt.Sprintf("%s:%d", config.Host, config.Port) + server := &http.Server{ + Addr: addr, + Handler: r, + ReadTimeout: time.Duration(config.ReadTimeout) * time.Second, + WriteTimeout: time.Duration(config.WriteTimeout) * time.Second, + IdleTimeout: time.Duration(config.IdleTimeout) * time.Second, + } + + // Start server in goroutine + go func() { + logStartupConfiguration(config, addr) + + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("Server failed: %v", err) + } + }() + + // Wait for interrupt signal for graceful shutdown + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + log.Println("Server shutting down...") + + // Graceful shutdown with timeout + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + if err := server.Shutdown(ctx); err != nil { + return fmt.Errorf("server forced to shutdown: %w", err) + } + + log.Println("Server exited") + return nil +} + +// Health check handler +func healthHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"status":"healthy","service":"testbed"}`)) +} + +// registerResourcePrefixes is defined in routes_generated.go diff --git a/cmd/server/metrics_helpers_generated.go b/cmd/server/metrics_helpers_generated.go new file mode 100644 index 0000000..dcb4435 --- /dev/null +++ b/cmd/server/metrics_helpers_generated.go @@ -0,0 +1,33 @@ +// Code generated by Fabrica v0.4.11 (commit: aa38d7c). DO NOT EDIT. +// Template: init/metrics_helpers.go.tmpl +// Generated: 2026-10-20T15:31:55Z +// +// SPDX-FileCopyrightText: Copyright © 2026 OpenCHAMI a Series of LF Projects, LLC +// +// SPDX-License-Identifier: MIT + +package main + +import "net/http" + +// Metrics is a stub type when metrics are disabled. +type Metrics struct{} + +// Middleware is a no-op stub when metrics are disabled. +func (m *Metrics) Middleware(next http.Handler) http.Handler { + return next +} + +// Handler is a no-op stub when metrics are disabled. +func (m *Metrics) Handler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.NotFound(w, r) + }) +} + +// initializeMetrics returns nil when metrics are disabled. +// This allows main.go to conditionally enable metrics at runtime +// based on the .fabrica.yaml configuration. +func initializeMetrics(config *Config) *Metrics { + return nil +} diff --git a/cmd/server/models_generated.go b/cmd/server/models_generated.go new file mode 100644 index 0000000..20f175a --- /dev/null +++ b/cmd/server/models_generated.go @@ -0,0 +1,107 @@ +// Code generated by Fabrica v0.4.11 (commit: aa38d7c). DO NOT EDIT. +// Copyright © 2026 OpenCHAMI a Series of LF Projects, LLC +// +// SPDX-License-Identifier: MIT +// +// This file contains request/response models for REST API operations. +// Generated from: pkg/codegen/templates/models.go.tmpl +// +// To modify request/response models: +// 1. Edit the template file: pkg/codegen/templates/models.go.tmpl +// 2. Run 'fabrica generate' to regenerate +// 3. Do NOT edit this file directly - changes will be lost +// +// Generated models for each resource: +// - ResourceResponse: Single resource response (alias to resource type) +// - ResourcesResponse: List of resources with pagination info +// - CreateResourceRequest: Create operation request body +// - UpdateResourceRequest: Update operation request body +// - DeleteResponse: Delete operation response +// +// Request structure: +// - Embeds resource Spec fields inline (json:",inline") +// - Includes metadata fields (Name, Labels, Annotations) +// - Uses validation tags for required fields +// +// To add custom validation: +// 1. Add validation tags to template (e.g., validate:"required,email") +// 2. Implement validation middleware in handlers +// +// To add additional fields: +// 1. Modify Create/Update request structs in template +// 2. Update corresponding handlers to process new fields +package main + +import ( + "encoding/json" + "fmt" + "net/http" + + "github.com/openchami/fabrica/pkg/fabrica" + + "github.com/openchami/github-actions-testbed/apis/testbed.openchami.io/v1" +) + +// WidgetResponse represents the response for Widget operations +type WidgetResponse = v1.Widget + +// CreateWidgetRequest represents a request to create a Widget +type CreateWidgetRequest struct { + Metadata fabrica.Metadata `json:"metadata" yaml:"metadata" validate:"required"` + Spec v1.WidgetSpec `json:"spec" yaml:"spec" validate:"required"` + Labels map[string]string `json:"labels,omitempty" yaml:"labels,omitempty"` + Annotations map[string]string `json:"annotations,omitempty" yaml:"annotations,omitempty"` +} + +// AsSpec converts the request fields to a spec object +func (r *CreateWidgetRequest) AsSpec() v1.WidgetSpec { + return r.Spec +} + +// UpdateWidgetRequest represents a request to update a Widget +type UpdateWidgetRequest struct { + Metadata fabrica.Metadata `json:"metadata,omitempty" yaml:"metadata,omitempty"` + Spec v1.WidgetSpec `json:"spec,omitempty" yaml:"spec,omitempty" validate:"omitempty"` + Labels map[string]string `json:"labels,omitempty" yaml:"labels,omitempty"` + Annotations map[string]string `json:"annotations,omitempty" yaml:"annotations,omitempty"` +} + +// AsSpec converts the request fields to a spec object +func (r *UpdateWidgetRequest) AsSpec() v1.WidgetSpec { + return r.Spec +} + +// ErrorResponse represents an error response +type ErrorResponse struct { + Error string `json:"error"` + Message string `json:"message,omitempty"` + Code int `json:"code,omitempty"` +} + +// DeleteResponse represents a successful deletion response +type DeleteResponse struct { + Message string `json:"message"` + UID string `json:"uid"` +} + +// Helper functions for handlers + +// respondJSON sends a JSON response +func respondJSON(w http.ResponseWriter, status int, data interface{}) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if err := json.NewEncoder(w).Encode(data); err != nil { + http.Error(w, fmt.Sprintf("failed to encode response: %v", err), http.StatusInternalServerError) + } +} + +// respondError sends an error response +func respondError(w http.ResponseWriter, status int, err error) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + response := ErrorResponse{ + Error: err.Error(), + Code: status, + } + json.NewEncoder(w).Encode(response) +} diff --git a/cmd/server/openapi_extensions.go b/cmd/server/openapi_extensions.go new file mode 100644 index 0000000..3c3dbaa --- /dev/null +++ b/cmd/server/openapi_extensions.go @@ -0,0 +1,47 @@ +// Copyright © 2026 OpenCHAMI a Series of LF Projects, LLC +// +// SPDX-License-Identifier: MIT +// +// This file contains the user-editable OpenAPI extension hook. +// +// ✅ This file is safe to edit: it will NOT be overwritten by regeneration. +// +// Add any routes that are not Fabrica-generated (legacy APIs, custom endpoints, +// WireGuard, cloud-init, etc.) to registerCustomOpenAPIPaths so they appear in +// the served OpenAPI spec and Swagger UI at /openapi.json and /docs. +// +// Example: +// +// func registerCustomOpenAPIPaths(spec *openapi3.T) { +// metaDataOp := openapi3.NewOperation() +// metaDataOp.OperationID = "getMetaData" +// metaDataOp.Summary = "Cloud-init meta-data endpoint" +// metaDataOp.Tags = []string{"cloud-init"} +// metaDataOp.Responses = openapi3.NewResponses() +// metaDataOp.Responses.Set("200", &openapi3.ResponseRef{ +// Value: openapi3.NewResponse().WithDescription("YAML metadata for the requesting node"), +// }) +// spec.Paths.Set("/meta-data", &openapi3.PathItem{Get: metaDataOp}) +// } +package main + +import "github.com/getkin/kin-openapi/openapi3" + +// registerCustomOpenAPIPaths is called by GenerateOpenAPISpec after all +// Fabrica-generated resource paths have been registered. +// Add your custom / non-generated route definitions here. +func registerCustomOpenAPIPaths(spec *openapi3.T) { + // Add custom route definitions here. + // Example (uncomment and extend as needed): + // + // op := openapi3.NewOperation() + // op.OperationID = "myCustomEndpoint" + // op.Summary = "My custom endpoint" + // op.Tags = []string{"Custom"} + // op.Responses = openapi3.NewResponses() + // op.Responses.Set("200", &openapi3.ResponseRef{ + // Value: openapi3.NewResponse().WithDescription("OK"), + // }) + // spec.Paths.Set("/my-endpoint", &openapi3.PathItem{Get: op}) + _ = spec +} diff --git a/cmd/server/openapi_generated.go b/cmd/server/openapi_generated.go new file mode 100644 index 0000000..4b0edf4 --- /dev/null +++ b/cmd/server/openapi_generated.go @@ -0,0 +1,439 @@ +// Code generated by Fabrica v0.4.11 (commit: aa38d7c). DO NOT EDIT. +// Copyright © 2026 OpenCHAMI a Series of LF Projects, LLC +// +// SPDX-License-Identifier: MIT +// +// This file contains OpenAPI 3.0 specification generation for all resources. +// Generated from: pkg/codegen/templates/server/openapi.go.tmpl +// +// To modify OpenAPI spec: +// 1. Edit the template file: pkg/codegen/templates/server/openapi.go.tmpl +// 2. Run 'fabrica generate' to regenerate +// 3. Do NOT edit this file directly - changes will be lost +// +// OpenAPI endpoints: +// - GET /openapi.json - Returns OpenAPI 3.0 spec +// - GET /docs - Returns Swagger UI +// +// This file automatically generates OpenAPI schemas from Go types using +// kin-openapi's openapi3gen package. No docstring annotations required. +package main + +import ( + "encoding/json" + "net/http" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/getkin/kin-openapi/openapi3gen" + "github.com/openchami/github-actions-testbed/apis/testbed.openchami.io/v1" +) + +// ServeOpenAPISpec returns the OpenAPI 3.0 specification +func ServeOpenAPISpec(w http.ResponseWriter, r *http.Request) { + spec := GenerateOpenAPISpec() + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(spec); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } +} + +// ServeSwaggerUI returns the Swagger UI HTML page +func ServeSwaggerUI(w http.ResponseWriter, r *http.Request) { + html := ` + + + + github_actions_testbed API Documentation + + + + +
+ + + + +` + w.Header().Set("Content-Type", "text/html") + w.Write([]byte(html)) +} + +// GenerateOpenAPISpec generates the complete OpenAPI 3.0 specification +func GenerateOpenAPISpec() *openapi3.T { + spec := &openapi3.T{ + OpenAPI: "3.0.0", + Info: &openapi3.Info{ + Title: "github_actions_testbed API", + Description: "Fabrica-generated REST API with Kubernetes-style resource management", + Version: "1.0.0", + Contact: &openapi3.Contact{ + Name: "Fabrica Project", + URL: "https://github.com/openchami/fabrica", + }, + }, + Servers: openapi3.Servers{ + { + URL: "http://localhost:8080", + Description: "Development server", + }, + }, + Paths: openapi3.NewPaths(), + Components: &openapi3.Components{ + Schemas: make(openapi3.Schemas), + }, + } + + // Register all resource paths + registerWidgetPaths(spec) + + // Register /health endpoint + healthOp := openapi3.NewOperation() + healthOp.OperationID = "health" + healthOp.Summary = "Service health" + healthOp.Description = "Returns service health information" + healthOp.Tags = []string{"Service"} + healthResponseSchema := openapi3.NewObjectSchema(). + WithProperty("status", openapi3.NewStringSchema()). + WithProperty("service", openapi3.NewStringSchema()). + WithProperty("version", openapi3.NewStringSchema()). + WithProperty("time", openapi3.NewStringSchema()). + WithRequired([]string{"status"}) + healthExample := map[string]interface{}{ + "status": "ok", + "service": "github_actions_testbed", + "version": "1.0.0", + "time": "2026-01-01T00:00:00Z", + } + healthResponseSchema.Example = healthExample + healthOp.Responses = openapi3.NewResponses() + healthOp.Responses.Set("200", &openapi3.ResponseRef{ + Value: openapi3.NewResponse(). + WithDescription("Healthy"). + WithContent(map[string]*openapi3.MediaType{ + "application/json": { + Schema: &openapi3.SchemaRef{Value: healthResponseSchema}, + Example: healthExample, + }, + }), + }) + spec.Paths.Set("/health", &openapi3.PathItem{Get: healthOp}) + + // Register /openapi.json endpoint (self-referential spec endpoint) + openAPIJSONExample := map[string]interface{}{ + "openapi": "3.0.0", + "info": map[string]interface{}{ + "title": "github_actions_testbed API", + "version": "1.0.0", + }, + } + openAPIJsonOp := openapi3.NewOperation() + openAPIJsonOp.OperationID = "getOpenAPISpec" + openAPIJsonOp.Summary = "Get OpenAPI specification" + openAPIJsonOp.Description = "Returns the complete OpenAPI 3.0 specification for this API" + openAPIJsonOp.Tags = []string{"Service"} + openAPIJsonOp.Responses = openapi3.NewResponses() + openAPIJsonOp.Responses.Set("200", &openapi3.ResponseRef{ + Value: openapi3.NewResponse(). + WithDescription("OpenAPI specification"). + WithContent(map[string]*openapi3.MediaType{ + "application/json": { + Schema: &openapi3.SchemaRef{Value: openapi3.NewObjectSchema()}, + Example: openAPIJSONExample, + }, + }), + }) + spec.Paths.Set("/openapi.json", &openapi3.PathItem{Get: openAPIJsonOp}) + + // Register /docs endpoint (Swagger UI) + docsOp := openapi3.NewOperation() + docsOp.OperationID = "getSwaggerUI" + docsOp.Summary = "API documentation UI" + docsOp.Description = "Returns an interactive Swagger UI for exploring the API" + docsOp.Tags = []string{"Service"} + docsOp.Responses = openapi3.NewResponses() + docsOp.Responses.Set("200", &openapi3.ResponseRef{ + Value: openapi3.NewResponse(). + WithDescription("Swagger UI HTML page"). + WithContent(map[string]*openapi3.MediaType{ + "text/html": { + Schema: &openapi3.SchemaRef{Value: openapi3.NewStringSchema()}, + }, + }), + }) + spec.Paths.Set("/docs", &openapi3.PathItem{Get: docsOp}) + + // Register custom (non-Fabrica-generated) paths. + // Defined in openapi_extensions.go – safe to edit, never overwritten. + registerCustomOpenAPIPaths(spec) + + return spec +} + +// registerWidgetPaths registers OpenAPI paths for Widget resources +func registerWidgetPaths(spec *openapi3.T) { + // Generate schemas from Go types - NO ANNOTATIONS NEEDED + resourceSchema, _ := openapi3gen.NewSchemaRefForValue(&v1.Widget{}, spec.Components.Schemas) + spec.Components.Schemas["Widget"] = resourceSchema + + createReqSchema, _ := openapi3gen.NewSchemaRefForValue(&CreateWidgetRequest{}, spec.Components.Schemas) + spec.Components.Schemas["CreateWidgetRequest"] = createReqSchema + + updateReqSchema, _ := openapi3gen.NewSchemaRefForValue(&UpdateWidgetRequest{}, spec.Components.Schemas) + spec.Components.Schemas["UpdateWidgetRequest"] = updateReqSchema + + statusSchema, _ := openapi3gen.NewSchemaRefForValue(&v1.WidgetStatus{}, spec.Components.Schemas) + spec.Components.Schemas["WidgetStatus"] = statusSchema + + // Error response schema + if _, exists := spec.Components.Schemas["ErrorResponse"]; !exists { + errorSchema := openapi3.NewObjectSchema(). + WithProperty("error", openapi3.NewStringSchema()). + WithRequired([]string{"error"}) + spec.Components.Schemas["ErrorResponse"] = &openapi3.SchemaRef{Value: errorSchema} + } + + // DELETE response schema + if _, exists := spec.Components.Schemas["DeleteResponse"]; !exists { + deleteSchema, _ := openapi3gen.NewSchemaRefForValue(&DeleteResponse{}, spec.Components.Schemas) + spec.Components.Schemas["DeleteResponse"] = deleteSchema + } + + // List Widgets operation + listOp := openapi3.NewOperation() + listOp.OperationID = "listWidgets" + listOp.Summary = "List all Widget resources" + listOp.Description = "Returns a list of all Widget resources in the inventory" + listOp.Tags = []string{"Widget"} + listOp.Responses = openapi3.NewResponses() + arraySchema := openapi3.NewArraySchema() + arraySchema.Items = &openapi3.SchemaRef{Ref: "#/components/schemas/Widget"} + listOp.Responses.Set("200", &openapi3.ResponseRef{ + Value: openapi3.NewResponse(). + WithDescription("Successful response"). + WithJSONSchemaRef(&openapi3.SchemaRef{Value: arraySchema}), + }) + listOp.Responses.Set("500", errorResponse()) + + // Create Widget operation + createOp := openapi3.NewOperation() + createOp.OperationID = "createWidget" + createOp.Summary = "Create a new Widget resource" + createOp.Description = "Creates a new Widget resource with the provided specification" + createOp.Tags = []string{"Widget"} + createOp.RequestBody = &openapi3.RequestBodyRef{ + Value: openapi3.NewRequestBody(). + WithRequired(true). + WithJSONSchemaRef(&openapi3.SchemaRef{ + Ref: "#/components/schemas/CreateWidgetRequest", + }), + } + createOp.Responses = openapi3.NewResponses() + createOp.Responses.Set("201", &openapi3.ResponseRef{ + Value: openapi3.NewResponse(). + WithDescription("Resource created successfully"). + WithJSONSchemaRef(&openapi3.SchemaRef{ + Ref: "#/components/schemas/Widget", + }), + }) + createOp.Responses.Set("400", errorResponse()) + createOp.Responses.Set("500", errorResponse()) + + // Get Widget operation + getOp := openapi3.NewOperation() + getOp.OperationID = "getWidget" + getOp.Summary = "Get a specific Widget resource" + getOp.Description = "Returns details of a specific Widget resource by UID" + getOp.Tags = []string{"Widget"} + getOp.Responses = openapi3.NewResponses() + getOp.Responses.Set("200", &openapi3.ResponseRef{ + Value: openapi3.NewResponse(). + WithDescription("Successful response"). + WithJSONSchemaRef(&openapi3.SchemaRef{ + Ref: "#/components/schemas/Widget", + }), + }) + getOp.Responses.Set("404", errorResponse()) + getOp.Responses.Set("500", errorResponse()) + + // Update Widget operation + updateOp := openapi3.NewOperation() + updateOp.OperationID = "updateWidget" + updateOp.Summary = "Update a Widget resource" + updateOp.Description = "Updates an existing Widget resource with new values" + updateOp.Tags = []string{"Widget"} + updateOp.RequestBody = &openapi3.RequestBodyRef{ + Value: openapi3.NewRequestBody(). + WithRequired(true). + WithJSONSchemaRef(&openapi3.SchemaRef{ + Ref: "#/components/schemas/UpdateWidgetRequest", + }), + } + updateOp.Responses = openapi3.NewResponses() + updateOp.Responses.Set("200", &openapi3.ResponseRef{ + Value: openapi3.NewResponse(). + WithDescription("Resource updated successfully"). + WithJSONSchemaRef(&openapi3.SchemaRef{ + Ref: "#/components/schemas/Widget", + }), + }) + updateOp.Responses.Set("400", errorResponse()) + updateOp.Responses.Set("404", errorResponse()) + updateOp.Responses.Set("500", errorResponse()) + + // Patch Widget operation + patchOp := openapi3.NewOperation() + patchOp.OperationID = "patchWidget" + patchOp.Summary = "Patch a Widget resource" + patchOp.Description = "Partially updates an existing Widget resource using patch semantics" + patchOp.Tags = []string{"Widget"} + patchOp.RequestBody = patchRequestBody() + patchOp.Responses = openapi3.NewResponses() + patchOp.Responses.Set("200", &openapi3.ResponseRef{ + Value: openapi3.NewResponse(). + WithDescription("Resource patched successfully"). + WithJSONSchemaRef(&openapi3.SchemaRef{ + Ref: "#/components/schemas/Widget", + }), + }) + patchOp.Responses.Set("400", errorResponse()) + patchOp.Responses.Set("404", errorResponse()) + patchOp.Responses.Set("422", errorResponse()) + patchOp.Responses.Set("500", errorResponse()) + + // Update Widget status operation + updateStatusOp := openapi3.NewOperation() + updateStatusOp.OperationID = "updateWidgetStatus" + updateStatusOp.Summary = "Update Widget status" + updateStatusOp.Description = "Updates only the status subresource for an existing Widget" + updateStatusOp.Tags = []string{"Widget"} + updateStatusOp.RequestBody = &openapi3.RequestBodyRef{ + Value: openapi3.NewRequestBody(). + WithRequired(true). + WithJSONSchemaRef(&openapi3.SchemaRef{Ref: "#/components/schemas/WidgetStatus"}), + } + updateStatusOp.Responses = openapi3.NewResponses() + updateStatusOp.Responses.Set("200", &openapi3.ResponseRef{ + Value: openapi3.NewResponse(). + WithDescription("Status updated successfully"). + WithJSONSchemaRef(&openapi3.SchemaRef{Ref: "#/components/schemas/Widget"}), + }) + updateStatusOp.Responses.Set("400", errorResponse()) + updateStatusOp.Responses.Set("404", errorResponse()) + updateStatusOp.Responses.Set("500", errorResponse()) + + // Patch Widget status operation + patchStatusOp := openapi3.NewOperation() + patchStatusOp.OperationID = "patchWidgetStatus" + patchStatusOp.Summary = "Patch Widget status" + patchStatusOp.Description = "Partially updates only the status subresource for an existing Widget" + patchStatusOp.Tags = []string{"Widget"} + patchStatusOp.RequestBody = patchRequestBody() + patchStatusOp.Responses = openapi3.NewResponses() + patchStatusOp.Responses.Set("200", &openapi3.ResponseRef{ + Value: openapi3.NewResponse(). + WithDescription("Status patched successfully"). + WithJSONSchemaRef(&openapi3.SchemaRef{Ref: "#/components/schemas/Widget"}), + }) + patchStatusOp.Responses.Set("400", errorResponse()) + patchStatusOp.Responses.Set("404", errorResponse()) + patchStatusOp.Responses.Set("422", errorResponse()) + patchStatusOp.Responses.Set("500", errorResponse()) + + // Delete Widget operation + deleteOp := openapi3.NewOperation() + deleteOp.OperationID = "deleteWidget" + deleteOp.Summary = "Delete a Widget resource" + deleteOp.Description = "Removes a Widget resource from the inventory" + deleteOp.Tags = []string{"Widget"} + deleteOp.Responses = openapi3.NewResponses() + deleteOp.Responses.Set("200", &openapi3.ResponseRef{ + Value: openapi3.NewResponse(). + WithDescription("Resource deleted successfully"). + WithJSONSchemaRef(&openapi3.SchemaRef{ + Ref: "#/components/schemas/DeleteResponse", + }), + }) + deleteOp.Responses.Set("400", errorResponse()) + deleteOp.Responses.Set("404", errorResponse()) + deleteOp.Responses.Set("500", errorResponse()) + + // Create path items + collectionPath := &openapi3.PathItem{ + Get: listOp, + Post: createOp, + } + + uidParam := openapi3.NewPathParameter("uid"). + WithDescription("Unique identifier of the Widget resource"). + WithRequired(true). + WithSchema(openapi3.NewStringSchema()) + + itemPath := &openapi3.PathItem{ + Get: getOp, + Put: updateOp, + Patch: patchOp, + Delete: deleteOp, + Parameters: []*openapi3.ParameterRef{ + {Value: uidParam}, + }, + } + + statusPath := &openapi3.PathItem{ + Put: updateStatusOp, + Patch: patchStatusOp, + Parameters: []*openapi3.ParameterRef{ + {Value: uidParam}, + }, + } + + // Add paths to spec + spec.Paths.Set("/widgets", collectionPath) + spec.Paths.Set("/widgets/{uid}", itemPath) + spec.Paths.Set("/widgets/{uid}/status", statusPath) +} + +func patchRequestBody() *openapi3.RequestBodyRef { + patchSchema := &openapi3.SchemaRef{Value: openapi3.NewObjectSchema()} + return &openapi3.RequestBodyRef{ + Value: &openapi3.RequestBody{ + Required: true, + Content: openapi3.Content{ + "application/merge-patch+json": &openapi3.MediaType{Schema: patchSchema}, + "application/json-patch+json": &openapi3.MediaType{Schema: patchSchema}, + "application/shorthand-patch": &openapi3.MediaType{Schema: patchSchema}, + "application/json": &openapi3.MediaType{Schema: patchSchema}, + }, + }, + } +} + +// Helper function for error responses +func errorResponse() *openapi3.ResponseRef { + return &openapi3.ResponseRef{ + Value: openapi3.NewResponse(). + WithDescription("Error response"). + WithJSONSchemaRef(&openapi3.SchemaRef{ + Ref: "#/components/schemas/ErrorResponse", + }), + } +} diff --git a/cmd/server/routes_generated.go b/cmd/server/routes_generated.go new file mode 100644 index 0000000..1120bbe --- /dev/null +++ b/cmd/server/routes_generated.go @@ -0,0 +1,75 @@ +// Code generated by Fabrica v0.4.11 (commit: aa38d7c). DO NOT EDIT. +// Copyright © 2026 OpenCHAMI a Series of LF Projects, LLC +// +// SPDX-License-Identifier: MIT +// +// This file contains REST API route registrations for all resources. +// Generated from: pkg/codegen/templates/routes.go.tmpl +// +// To modify route registration: +// 1. Edit the template file: pkg/codegen/templates/routes.go.tmpl +// 2. Run 'fabrica generate' to regenerate +// 3. Do NOT edit this file directly - changes will be lost +// +// This file registers routes for all resource types: +// - /widgets (Widget operations) +// +// Route patterns: +// - GET /resource -> List all resources +// - GET /resource/{uid} -> Get specific resource +// - POST /resource -> Create new resource +// - PUT /resource/{uid} -> Update resource spec +// - PATCH /resource/{uid} -> Patch resource spec +// - DELETE /resource/{uid} -> Delete resource +// - PUT /resource/{uid}/status -> Update resource status +// - PATCH /resource/{uid}/status -> Patch resource status +// +// To add middleware to routes: +// 1. Apply middleware in cmd/server/main.go before calling RegisterGeneratedRoutes +// 2. Use r.Use() calls in main.go, not in generated route functions +// +// To add custom routes: +// 1. Create a separate RegisterCustomRoutes function +// 2. Call it after RegisterGeneratedRoutes in main.go +package main + +import ( + "github.com/go-chi/chi/v5" + "github.com/openchami/fabrica/pkg/resource" +) + +// registerResourcePrefixes registers UID prefixes for all resource types +// This must be called before any resource creation operations +func registerResourcePrefixes() error { + + resource.RegisterResourcePrefix("Widget", "widget") + + return nil +} + +// RegisterGeneratedRoutes registers all generated resource routes. +// The router passed to this function should already have middleware applied in main.go. +// Public service routes such as /health, /openapi.json, and /docs are registered +// in cmd/server/main.go so they remain outside auth middleware. +func RegisterGeneratedRoutes(r chi.Router) { + // Routes inherit middleware from the router passed by main.go + + // Widget routes + r.Route("/widgets", func(resource chi.Router) { + resource.Get("/", GetWidgets) + resource.Post("/", CreateWidget) + resource.Route("/{uid}", func(item chi.Router) { + item.Get("/", GetWidget) + item.Put("/", UpdateWidget) + item.Patch("/", PatchWidget) + item.Delete("/", DeleteWidget) + + // Status subresource + item.Route("/status", func(status chi.Router) { + status.Put("/", UpdateWidgetStatus) + status.Patch("/", PatchWidgetStatus) + }) + }) + }) + +} diff --git a/cmd/server/runtime_helpers_generated.go b/cmd/server/runtime_helpers_generated.go new file mode 100644 index 0000000..bb58215 --- /dev/null +++ b/cmd/server/runtime_helpers_generated.go @@ -0,0 +1,55 @@ + +// Code generated by Fabrica v0.4.11. DO NOT EDIT. +// Template: init/runtime_helpers.go.tmpl +// Generated: 2026-10-20T15:32:26Z +// +// SPDX-FileCopyrightText: Copyright © 2026 OpenCHAMI a Series of LF Projects, LLC +// +// SPDX-License-Identifier: MIT + +package main + + +import ( + + "fmt" + + + "log" + + "github.com/openchami/github-actions-testbed/internal/storage" + + + + +) + + +func initializeStorage(config *Config) (func(), error) { + + + if err := storage.InitFileBackend(config.DataDir); err != nil { + return nil, fmt.Errorf("failed to initialize file storage: %w", err) + } + log.Printf("File storage initialized in %s", config.DataDir) + return func() {}, nil + + +} + +func initializeEventingAndReconciliation(config *Config) (func(), error) { + + + return func() {}, nil + +} + +func logStartupConfiguration(config *Config, addr string) { + log.Printf("Server starting on %s", addr) + + + log.Printf("Storage: file backend in %s", config.DataDir) + + + +} diff --git a/cmd/server/version_generated.go b/cmd/server/version_generated.go new file mode 100644 index 0000000..fd9e041 --- /dev/null +++ b/cmd/server/version_generated.go @@ -0,0 +1,57 @@ +// Code generated by Fabrica v0.4.11 (commit: aa38d7c). DO NOT EDIT. +// Template: server/version.go.tmpl +// Generated: 2026-10-20T15:31:55Z +// +// SPDX-FileCopyrightText: Copyright © 2026 OpenCHAMI a Series of LF Projects, LLC +// +// SPDX-License-Identifier: MIT + +package main + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +// Service version information - set via ldflags at build time +var ( + serviceVersion = "dev" + serviceCommit = "none" + serviceDate = "unknown" +) + +// fabricaVersion is embedded at generation time +const fabricaVersion = "v0.4.11" + +func serviceVersionString() string { + if serviceCommit == "none" && serviceDate == "unknown" { + return serviceVersion + } + if serviceCommit == "none" { + return fmt.Sprintf("%s (built: %s)", serviceVersion, serviceDate) + } + if serviceDate == "unknown" { + return fmt.Sprintf("%s (commit: %s)", serviceVersion, serviceCommit) + } + return fmt.Sprintf("%s (commit: %s, built: %s)", serviceVersion, serviceCommit, serviceDate) +} + +// NewVersionCommand creates the version command for the server +func NewVersionCommand() *cobra.Command { + return &cobra.Command{ + Use: "version", + Short: "Print version information", + Long: `Print version information for github_actions_testbed`, + Run: func(cmd *cobra.Command, args []string) { + fmt.Printf("github_actions_testbed version %s\n", serviceVersion) + if serviceCommit != "none" { + fmt.Printf(" commit: %s\n", serviceCommit) + } + if serviceDate != "unknown" { + fmt.Printf(" built: %s\n", serviceDate) + } + fmt.Printf(" generated by: Fabrica %s\n", fabricaVersion) + }, + } +} diff --git a/cmd/server/widget_handlers_generated.go b/cmd/server/widget_handlers_generated.go new file mode 100644 index 0000000..2d16f7d --- /dev/null +++ b/cmd/server/widget_handlers_generated.go @@ -0,0 +1,496 @@ +// Code generated by Fabrica v0.4.11 (commit: aa38d7c). DO NOT EDIT. +// Template: server/handlers.go.tmpl +// Generated: 2026-10-20T15:31:55Z +// +// # Copyright © 2026 OpenCHAMI a Series of LF Projects, LLC +// +// SPDX-License-Identifier: MIT +// +// This file contains REST API handlers for Widget resources. +// +// To modify this code: +// 1. Edit the template file: pkg/codegen/templates/handlers.go.tmpl +// 2. Run 'fabrica generate' to regenerate +// 3. Do NOT edit this file directly - changes will be lost +// +// Generated handlers provide: +// - GET /widgets (list all widgets) +// - GET /widgets/{uid} (get specific Widget) +// - POST /widgets (create new Widget) +// - PUT /widgets/{uid} (update Widget spec) +// - PATCH /widgets/{uid} (patch Widget spec) +// - DELETE /widgets/{uid} (delete Widget) +// - PUT /widgets/{uid}/status (update Widget status) +// - PATCH /widgets/{uid}/status (patch Widget status) +// +// Authorization: Add custom middleware for authentication/authorization +// Storage: Uses storage.LoadWidget*/SaveWidget*/DeleteWidget* +// Version Support: Available (see version context in handlers) +// +// To enable full version conversion for this resource: +// 1. Add new version: fabrica add version +// 2. Implement converter: apis///converter.go +// 3. Add version-aware storage: storage.LoadWidgetWithVersion() +// 4. Register versions in cmd/server/main.go +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + "github.com/go-chi/chi/v5" + "github.com/openchami/fabrica/pkg/events" + "github.com/openchami/fabrica/pkg/patch" + "github.com/openchami/fabrica/pkg/resource" + "github.com/openchami/fabrica/pkg/validation" + "github.com/openchami/fabrica/pkg/versioning" + v1 "github.com/openchami/github-actions-testbed/apis/testbed.openchami.io/v1" + + "github.com/openchami/github-actions-testbed/internal/storage" +) + +// GetWidgets returns all Widget resources +func GetWidgets(w http.ResponseWriter, r *http.Request) { + // Authorization: Add custom middleware in routes.go or implement checks here + // Example: if !authorized(r) { respondError(w, http.StatusUnauthorized, fmt.Errorf("unauthorized")); return } + + widgets, err := storage.LoadAllWidgets(r.Context()) + if err != nil { + respondError(w, http.StatusInternalServerError, fmt.Errorf("failed to load widgets: %w", err)) + return + } + respondJSON(w, http.StatusOK, widgets) +} + +// GetWidget returns a specific Widget resource by UID +func GetWidget(w http.ResponseWriter, r *http.Request) { + uid := chi.URLParam(r, "uid") + if uid == "" { + respondError(w, http.StatusBadRequest, fmt.Errorf("Widget UID is required")) + return + } + + // Version context available here for version-aware operations + // versionCtx := versioning.GetVersionContext(r.Context()) + // Requested version: versionCtx.ServeVersion + // To enable: replace storage.LoadWidget() with version-aware function + + // Authorization: Add custom middleware in routes.go or implement checks here + // Example: if !authorized(r) { respondError(w, http.StatusUnauthorized, fmt.Errorf("unauthorized")); return } + + widget, err := storage.LoadWidget(r.Context(), uid) + if err != nil { + respondError(w, http.StatusNotFound, fmt.Errorf("Widget not found: %w", err)) + return + } + respondJSON(w, http.StatusOK, widget) +} + +// CreateWidget creates a new Widget resource +func CreateWidget(w http.ResponseWriter, r *http.Request) { + var req CreateWidgetRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + respondError(w, http.StatusBadRequest, fmt.Errorf("invalid request body: %w", err)) + return + } + + // Layer 1: Request validation (validates inline spec fields and metadata) + if err := validation.ValidateResource(&req); err != nil { + respondError(w, http.StatusBadRequest, fmt.Errorf("validation failed: %w", err)) + return + } + + // Get version context from request (set by version negotiation middleware) + versionCtx := versioning.GetVersionContext(r.Context()) + + uid, err := resource.GenerateUIDForResource("Widget") + if err != nil { + respondError(w, http.StatusInternalServerError, fmt.Errorf("failed to generate UID: %w", err)) + return + } + + // Versioned mode: flat fields with fabrica.Metadata + widget := &v1.Widget{ + // Use negotiated ServeVersion (from Accept header) for apiVersion + APIVersion: versionCtx.ServeVersion, + Kind: "Widget", + Spec: req.AsSpec(), + } + + // Initialize metadata from request + widget.Metadata = req.Metadata + widget.Metadata.UID = uid + now := time.Now() + widget.Metadata.CreatedAt = now + widget.Metadata.UpdatedAt = now + + // Set labels and annotations + if widget.Metadata.Labels == nil { + widget.Metadata.Labels = make(map[string]string) + } + for k, v := range req.Labels { + widget.Metadata.Labels[k] = v + } + if widget.Metadata.Annotations == nil { + widget.Metadata.Annotations = make(map[string]string) + } + for k, v := range req.Annotations { + widget.Metadata.Annotations[k] = v + } + + // Layer 2: Custom business logic validation + if err := validation.ValidateWithContext(r.Context(), widget); err != nil { + respondError(w, http.StatusBadRequest, fmt.Errorf("validation failed: %w", err)) + return + } + + // Set initial status + // This assumes the generator passes an 'IsReconcilable' boolean + // to this template, and that the resource has a .Status.Phase field. + + // Save (Layer 1: Ent validation happens automatically if using Ent storage) + if err := storage.SaveWidget(r.Context(), widget); err != nil { + respondError(w, http.StatusInternalServerError, fmt.Errorf("failed to save Widget: %w", err)) + return + } + + // Publish resource created event + + if err := events.PublishResourceCreated(r.Context(), "Widget", widget.Metadata.UID, widget.Metadata.Name, widget); err != nil { + // Log the error but don't fail the request - events are non-critical + fmt.Printf("Warning: Failed to publish resource created event for Widget %s: %v\n", widget.Metadata.UID, err) + + } + + respondJSON(w, http.StatusCreated, widget) +} + +// UpdateWidget updates the spec of an existing Widget resource +// NOTE: This endpoint ONLY updates the spec. Use PUT //widgets/{uid}/status to update status. +func UpdateWidget(w http.ResponseWriter, r *http.Request) { + uid := chi.URLParam(r, "uid") + if uid == "" { + respondError(w, http.StatusBadRequest, fmt.Errorf("Widget UID is required")) + return + } + + widget, err := storage.LoadWidget(r.Context(), uid) + if err != nil { + respondError(w, http.StatusNotFound, fmt.Errorf("Widget not found: %w", err)) + return + } + + var req UpdateWidgetRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + respondError(w, http.StatusBadRequest, fmt.Errorf("invalid request body: %w", err)) + return + } + + // Apply updates + + // Versioned mode: direct field access + if req.Metadata.Name != "" { + widget.Metadata.Name = req.Metadata.Name + } + + // Update spec fields ONLY - status should use /status subresource + widget.Spec = req.AsSpec() + + // Update labels and annotations + if widget.Metadata.Labels == nil { + widget.Metadata.Labels = make(map[string]string) + } + for k, v := range req.Labels { + widget.Metadata.Labels[k] = v + } + if widget.Metadata.Annotations == nil { + widget.Metadata.Annotations = make(map[string]string) + } + for k, v := range req.Annotations { + widget.Metadata.Annotations[k] = v + } + + // Update timestamp + widget.Metadata.UpdatedAt = time.Now() + + // Layer 2: Custom business logic validation + if err := validation.ValidateWithContext(r.Context(), widget); err != nil { + respondError(w, http.StatusBadRequest, fmt.Errorf("validation failed: %w", err)) + return + } + + if err := storage.SaveWidget(r.Context(), widget); err != nil { + respondError(w, http.StatusInternalServerError, fmt.Errorf("failed to save Widget: %w", err)) + return + } + + // Publish resource updated event + updateMetadata := map[string]interface{}{ + "updatedAt": widget.Metadata.UpdatedAt, + } + + if err := events.PublishResourceUpdated(r.Context(), "Widget", widget.Metadata.UID, widget.Metadata.Name, widget, updateMetadata); err != nil { + // Log the error but don't fail the request - events are non-critical + fmt.Printf("Warning: Failed to publish resource updated event for Widget %s: %v\n", widget.Metadata.UID, err) + + } + + respondJSON(w, http.StatusOK, widget) +} + +// PatchWidget patches an existing Widget resource spec using JSON Merge Patch, JSON Patch, or Shorthand Patch +// Only the spec portion of the resource can be patched - metadata and status are API-managed +func PatchWidget(w http.ResponseWriter, r *http.Request) { + uid := chi.URLParam(r, "uid") + if uid == "" { + respondError(w, http.StatusBadRequest, fmt.Errorf("Widget UID is required")) + return + } + + widget, err := storage.LoadWidget(r.Context(), uid) + if err != nil { + respondError(w, http.StatusNotFound, fmt.Errorf("Widget not found: %w", err)) + return + } + + // Read patch document + patchData, err := io.ReadAll(r.Body) + if err != nil { + respondError(w, http.StatusBadRequest, fmt.Errorf("failed to read patch data: %w", err)) + return + } + + // Marshal current spec to JSON for patching (only allow spec modifications) + currentSpecJSON, err := json.Marshal(widget.Spec) + if err != nil { + respondError(w, http.StatusInternalServerError, fmt.Errorf("failed to marshal current spec: %w", err)) + return + } + + // Detect patch type from Content-Type header + contentType := r.Header.Get("Content-Type") + patchType := patch.DetectPatchType(contentType) + + // Apply patch to spec only + patchResult, err := patch.ApplyPatchWithOptions(currentSpecJSON, patchData, patchType, patch.PatchOptions{ + AllowAddFields: true, + AllowRemoveFields: true, + }) + if err != nil { + respondError(w, http.StatusUnprocessableEntity, fmt.Errorf("failed to apply patch to spec: %w", err)) + return + } + + // Unmarshal the patched result back to the spec + if err := json.Unmarshal(patchResult.Updated, &widget.Spec); err != nil { + respondError(w, http.StatusInternalServerError, fmt.Errorf("failed to unmarshal patched spec: %w", err)) + return + } + + // Touch to update metadata + + widget.Metadata.UpdatedAt = time.Now() + + // Layer 2: Custom business logic validation + if err := validation.ValidateWithContext(r.Context(), widget); err != nil { + respondError(w, http.StatusBadRequest, fmt.Errorf("validation failed: %w", err)) + return + } + + // Save the patched resource + if err := storage.SaveWidget(r.Context(), widget); err != nil { + respondError(w, http.StatusInternalServerError, fmt.Errorf("failed to save patched Widget: %w", err)) + return + } + + // Publish resource patched event + patchMetadata := map[string]interface{}{ + "patchType": patchType, + "updatedAt": widget.Metadata.UpdatedAt, + } + + if err := events.PublishResourcePatched(r.Context(), "Widget", widget.Metadata.UID, widget.Metadata.Name, widget, patchMetadata); err != nil { + // Log the error but don't fail the request - events are non-critical + fmt.Printf("Warning: Failed to publish resource patched event for Widget %s: %v\n", widget.Metadata.UID, err) + + } + + respondJSON(w, http.StatusOK, widget) +} + +// UpdateWidgetStatus updates only the status of a Widget resource +// This endpoint is intended for controllers, reconcilers, and monitoring systems. +// It does not modify the spec or metadata (except updatedAt timestamp). +// +// Authorization: Requires 'update_status' permission (separate from 'update' permission) +// Events: Publishes resource updated event with updateType: "status" +func UpdateWidgetStatus(w http.ResponseWriter, r *http.Request) { + uid := chi.URLParam(r, "uid") + if uid == "" { + respondError(w, http.StatusBadRequest, fmt.Errorf("Widget UID is required")) + return + } + + // Authorization: Add custom middleware for status update authorization + // Status updates can have different permissions than spec updates + + res, err := storage.LoadWidget(r.Context(), uid) + if err != nil { + respondError(w, http.StatusNotFound, fmt.Errorf("Widget not found: %w", err)) + return + } + + var statusUpdate v1.WidgetStatus + if err := json.NewDecoder(r.Body).Decode(&statusUpdate); err != nil { + respondError(w, http.StatusBadRequest, fmt.Errorf("invalid status body: %w", err)) + return + } + + // Preserve spec - only update status + res.Status = statusUpdate + + res.Metadata.UpdatedAt = time.Now() + + // Layer 2: Custom business logic validation + if err := validation.ValidateWithContext(r.Context(), res); err != nil { + respondError(w, http.StatusBadRequest, fmt.Errorf("validation failed: %w", err)) + return + } + + if err := storage.SaveWidget(r.Context(), res); err != nil { + respondError(w, http.StatusInternalServerError, fmt.Errorf("failed to save Widget status: %w", err)) + return + } + + // Publish status update event + statusMetadata := map[string]interface{}{ + "updatedAt": res.Metadata.UpdatedAt, + "updateType": "status", + } + + if err := events.PublishResourceUpdated(r.Context(), "Widget", res.Metadata.UID, res.Metadata.Name, res, statusMetadata); err != nil { + // Log but don't fail - events are non-critical + fmt.Printf("Warning: Failed to publish status update event for Widget %s: %v\n", res.Metadata.UID, err) + + } + + respondJSON(w, http.StatusOK, res) +} + +// PatchWidgetStatus patches only the status of a Widget resource +// Supports JSON Merge Patch, JSON Patch, and Shorthand Patch formats. +// Only modifies status fields - spec and metadata are preserved. +func PatchWidgetStatus(w http.ResponseWriter, r *http.Request) { + uid := chi.URLParam(r, "uid") + if uid == "" { + respondError(w, http.StatusBadRequest, fmt.Errorf("Widget UID is required")) + return + } + + // Authorization: Add custom middleware for status patch authorization + // Status patches can have different permissions than spec patches + + res, err := storage.LoadWidget(r.Context(), uid) + if err != nil { + respondError(w, http.StatusNotFound, fmt.Errorf("Widget not found: %w", err)) + return + } + + patchData, err := io.ReadAll(r.Body) + if err != nil { + respondError(w, http.StatusBadRequest, fmt.Errorf("failed to read patch data: %w", err)) + return + } + + // Marshal current status for patching + currentStatusJSON, err := json.Marshal(res.Status) + if err != nil { + respondError(w, http.StatusInternalServerError, fmt.Errorf("failed to marshal current status: %w", err)) + return + } + + contentType := r.Header.Get("Content-Type") + patchType := patch.DetectPatchType(contentType) + + patchResult, err := patch.ApplyPatchWithOptions(currentStatusJSON, patchData, patchType, patch.PatchOptions{ + AllowAddFields: true, + AllowRemoveFields: false, // Don't allow removing status fields + }) + if err != nil { + respondError(w, http.StatusUnprocessableEntity, fmt.Errorf("failed to apply patch to status: %w", err)) + return + } + + // Unmarshal patched status back + if err := json.Unmarshal(patchResult.Updated, &res.Status); err != nil { + respondError(w, http.StatusInternalServerError, fmt.Errorf("failed to unmarshal patched status: %w", err)) + return + } + + res.Metadata.UpdatedAt = time.Now() + + // Layer 2: Custom business logic validation + if err := validation.ValidateWithContext(r.Context(), res); err != nil { + respondError(w, http.StatusBadRequest, fmt.Errorf("validation failed: %w", err)) + return + } + + if err := storage.SaveWidget(r.Context(), res); err != nil { + respondError(w, http.StatusInternalServerError, fmt.Errorf("failed to save patched Widget status: %w", err)) + return + } + + // Publish status patch event + patchMetadata := map[string]interface{}{ + "patchType": patchType, + "updatedAt": res.Metadata.UpdatedAt, + "updateType": "status", + } + + if err := events.PublishResourcePatched(r.Context(), "Widget", res.Metadata.UID, res.Metadata.Name, res, patchMetadata); err != nil { + fmt.Printf("Warning: Failed to publish status patch event for Widget %s: %v\n", res.Metadata.UID, err) + + } + + respondJSON(w, http.StatusOK, res) +} + +// DeleteWidget deletes a Widget resource +func DeleteWidget(w http.ResponseWriter, r *http.Request) { + uid := chi.URLParam(r, "uid") + if uid == "" { + respondError(w, http.StatusBadRequest, fmt.Errorf("Widget UID is required")) + return + } + + // Load resource before deletion for event publishing + widget, err := storage.LoadWidget(r.Context(), uid) + if err != nil { + respondError(w, http.StatusNotFound, fmt.Errorf("Widget not found: %w", err)) + return + } + + if err := storage.DeleteWidget(r.Context(), uid); err != nil { + respondError(w, http.StatusInternalServerError, fmt.Errorf("failed to delete Widget: %w", err)) + return + } + + // Publish resource deleted event + deleteMetadata := map[string]interface{}{ + "deletedAt": time.Now(), + } + + if err := events.PublishResourceDeleted(r.Context(), "Widget", widget.Metadata.UID, widget.Metadata.Name, deleteMetadata); err != nil { + // Log the error but don't fail the request - events are non-critical + fmt.Printf("Warning: Failed to publish resource deleted event for Widget %s: %v\n", widget.Metadata.UID, err) + + } + + respondJSON(w, http.StatusOK, &DeleteResponse{ + Message: "Widget deleted successfully", + UID: uid, + }) +} diff --git a/cmd/testbed/main.go b/cmd/testbed/main.go deleted file mode 100644 index 5e05477..0000000 --- a/cmd/testbed/main.go +++ /dev/null @@ -1,24 +0,0 @@ -// SPDX-FileCopyrightText: © 2026 OpenCHAMI a Series of LF Projects, LLC -// SPDX-License-Identifier: MIT - -// Command testbed is a trivial binary used to exercise the -// OpenCHAMI/github-actions build and release workflows. -package main - -import "fmt" - -// Set via -ldflags by GoReleaser. -var ( - version = "dev" - commit = "none" - buildHost = "unknown" -) - -// Info returns the build metadata line printed by the binary. -func Info() string { - return fmt.Sprintf("testbed %s (commit %s, built on %s)", version, commit, buildHost) -} - -func main() { - fmt.Println(Info()) -} diff --git a/cmd/testbed/main_test.go b/cmd/testbed/main_test.go deleted file mode 100644 index 80dd8f2..0000000 --- a/cmd/testbed/main_test.go +++ /dev/null @@ -1,15 +0,0 @@ -// SPDX-FileCopyrightText: © 2026 OpenCHAMI a Series of LF Projects, LLC -// SPDX-License-Identifier: MIT - -package main - -import ( - "strings" - "testing" -) - -func TestInfo(t *testing.T) { - if got := Info(); !strings.HasPrefix(got, "testbed ") { - t.Fatalf("Info() = %q, want prefix %q", got, "testbed ") - } -} diff --git a/go.mod b/go.mod index 02e6168..153329f 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,50 @@ module github.com/openchami/github-actions-testbed -go 1.26 +go 1.26.10 + +require ( + github.com/getkin/kin-openapi v0.151.0 + github.com/go-chi/chi/v5 v5.5.0 + github.com/openchami/fabrica v0.4.11 + github.com/rs/zerolog v1.35.1 + github.com/spf13/cobra v1.10.2 + github.com/spf13/pflag v1.0.10 + github.com/spf13/viper v1.21.0 +) + +require ( + github.com/cloudevents/sdk-go/v2 v2.16.2 // indirect + github.com/evanphx/json-patch/v5 v5.9.11 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.13 // indirect + github.com/go-openapi/jsonpointer v0.22.5 // indirect + github.com/go-openapi/swag/jsonname v0.25.5 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.30.3 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/oasdiff/yaml v0.1.1 // indirect + github.com/oasdiff/yaml3 v0.0.14 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/sagikazarmark/locafero v0.11.0 // indirect + github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 // indirect + github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect + github.com/spf13/afero v1.15.0 // indirect + github.com/spf13/cast v1.10.0 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.28.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/crypto v0.53.0 // indirect + golang.org/x/sys v0.46.0 // indirect + golang.org/x/text v0.41.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..027708a --- /dev/null +++ b/go.sum @@ -0,0 +1,122 @@ +github.com/cloudevents/sdk-go/v2 v2.16.2 h1:ZYDFrYke4FD+jM8TZTJJO6JhKHzOQl2oqpFK1D+NnQM= +github.com/cloudevents/sdk-go/v2 v2.16.2/go.mod h1:laOcGImm4nVJEU+PHnUrKL56CKmRL65RlQF0kRmW/kg= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= +github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= +github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= +github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/getkin/kin-openapi v0.151.0 h1:p8NIstL53h620DnlO9R4pR4QRkEzJ3jbw9flb4kc1hg= +github.com/getkin/kin-openapi v0.151.0/go.mod h1:1+BHDzstro+P5CKtPy1X4PfofnFgmRe6uvMy9+r9fKY= +github.com/go-chi/chi/v5 v5.5.0 h1:xdKQ1dJmVJsmgahrXSKhUx8NyVPzpwdlyrrQFpMPJgg= +github.com/go-chi/chi/v5 v5.5.0/go.mod h1:d1IG7GtKPSjPyBP365PH8sAGVHk3qfB/IhjFNRH1wWM= +github.com/go-openapi/jsonpointer v0.22.5 h1:8on/0Yp4uTb9f4XvTrM2+1CPrV05QPZXu+rvu2o9jcA= +github.com/go-openapi/jsonpointer v0.22.5/go.mod h1:gyUR3sCvGSWchA2sUBJGluYMbe1zazrYWIkWPjjMUY0= +github.com/go-openapi/swag/jsonname v0.25.5 h1:8p150i44rv/Drip4vWI3kGi9+4W9TdI3US3uUYSFhSo= +github.com/go-openapi/swag/jsonname v0.25.5/go.mod h1:jNqqikyiAK56uS7n8sLkdaNY/uq6+D2m2LANat09pKU= +github.com/go-openapi/testify/v2 v2.4.0 h1:8nsPrHVCWkQ4p8h1EsRVymA2XABB4OT40gcvAu+voFM= +github.com/go-openapi/testify/v2 v2.4.0/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.30.3 h1:4MU6YkEwx7GbcPJOZxrtbu+QfF3pJLJuaYTeAH0DYy8= +github.com/go-playground/validator/v10 v10.30.3/go.mod h1:4Axh7oCNGcoGkqLoE4YWt6n20mcEIsPRlB7vPk3lpyc= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/oasdiff/yaml v0.1.1 h1:6nHx+pn9gBRM6YpBlFZFQGCCd1nuvqOBtTD3KKTgGxY= +github.com/oasdiff/yaml v0.1.1/go.mod h1:EYJNoyktvWMJ0Hmhx+6qTaqMOsalUaRGT8Sj1hNcegU= +github.com/oasdiff/yaml3 v0.0.14 h1:aLJee3hxBK2H5wdXd9iPcIXb93Nty1Ge0pT171eHtkw= +github.com/oasdiff/yaml3 v0.0.14/go.mod h1:csto2xfDjYccdUn/yw/bPjj/cYTdp6HtFA0J4TWG+gg= +github.com/openchami/fabrica v0.4.11 h1:D0nOIwDywIPiLbcevWJ/WhuyQO34PeuQxrqMzXPHUV8= +github.com/openchami/fabrica v0.4.11/go.mod h1:Gm9BCIjezT32qftya3CbK/mAjwnRCM5Puit3+jKGgZE= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc= +github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs= +github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI= +github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= +github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 h1:1EYB5IzjZawrrnELUi78f9fPu57HuXjmddZPjrls/28= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.3/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= +github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo= +go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +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/go.sum.license b/go.sum.license new file mode 100644 index 0000000..380948c --- /dev/null +++ b/go.sum.license @@ -0,0 +1,2 @@ +SPDX-FileCopyrightText: © 2026 OpenCHAMI a Series of LF Projects, LLC +SPDX-License-Identifier: MIT diff --git a/internal/middleware/conditional_middleware_generated.go b/internal/middleware/conditional_middleware_generated.go new file mode 100644 index 0000000..e029754 --- /dev/null +++ b/internal/middleware/conditional_middleware_generated.go @@ -0,0 +1,133 @@ +/* + * Copyright © 2026 OpenCHAMI a Series of LF Projects, LLC + * + * SPDX-License-Identifier: MIT + */ + +// Code generated by Fabrica v0.4.11 (commit: aa38d7c). DO NOT EDIT. +package server + +import ( + "crypto/md5" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + "strings" +) + +// ETagAlgorithm defines the hashing algorithm for ETags +// Configured in .fabrica.yaml: sha256 +const ETagAlgorithm = "sha256" // sha256, md5 + +// ConditionalMiddleware handles ETags and conditional requests +// +// Features: +// - Generates ETags for GET responses +// - Validates If-Match headers for PUT/PATCH/DELETE +// - Validates If-None-Match for conditional GET +// - Returns 304 Not Modified when appropriate +// - Returns 412 Precondition Failed on ETag mismatch +func ConditionalMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Set cache control headers + w.Header().Set("Cache-Control", "private, must-revalidate") + + next.ServeHTTP(w, r) + }) +} + +// GenerateETag generates an ETag for the given data +func GenerateETag(data interface{}) (string, error) { + // Marshal to JSON for consistent hashing + jsonData, err := json.Marshal(data) + if err != nil { + return "", fmt.Errorf("failed to marshal data: %w", err) + } + + var hash string + switch ETagAlgorithm { + case "sha256": + h := sha256.Sum256(jsonData) + hash = hex.EncodeToString(h[:]) + case "md5": + h := md5.Sum(jsonData) + hash = hex.EncodeToString(h[:]) + default: + return "", fmt.Errorf("unknown ETag algorithm: %s", ETagAlgorithm) + } + + // Format as W/"" (weak ETag) + return fmt.Sprintf(`W/"%s"`, hash[:16]), nil +} + +// CheckIfMatch validates If-Match header for conditional updates +// +// Returns: +// - true if the condition passes (update should proceed) +// - false if the condition fails (returns 412 Precondition Failed) +func CheckIfMatch(w http.ResponseWriter, r *http.Request, currentETag string) bool { + ifMatch := r.Header.Get("If-Match") + if ifMatch == "" { + // No If-Match header, allow update + return true + } + + // Parse If-Match (can be comma-separated list) + tags := strings.Split(ifMatch, ",") + for _, tag := range tags { + tag = strings.TrimSpace(tag) + if tag == "*" || tag == currentETag { + return true + } + } + + // ETag mismatch - return 412 Precondition Failed + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusPreconditionFailed) + json.NewEncoder(w).Encode(map[string]interface{}{ + "error": "Precondition failed", + "message": "Resource has been modified. Please fetch the latest version and retry.", + "current_etag": currentETag, + "provided_etag": ifMatch, + }) + return false +} + +// CheckIfNoneMatch validates If-None-Match header for conditional GET +// +// Returns: +// - true if the resource should be returned (modified) +// - false if 304 Not Modified should be returned +func CheckIfNoneMatch(w http.ResponseWriter, r *http.Request, currentETag string) bool { + ifNoneMatch := r.Header.Get("If-None-Match") + if ifNoneMatch == "" { + // No If-None-Match header, return resource + return true + } + + // Parse If-None-Match (can be comma-separated list) + tags := strings.Split(ifNoneMatch, ",") + for _, tag := range tags { + tag = strings.TrimSpace(tag) + if tag == "*" || tag == currentETag { + // ETag matches - return 304 Not Modified + w.WriteHeader(http.StatusNotModified) + return false + } + } + + // ETag doesn't match - return resource + return true +} + +// SetETag sets the ETag header on the response +func SetETag(w http.ResponseWriter, etag string) { + w.Header().Set("ETag", etag) +} + +// SetCacheControl sets cache control headers +func SetCacheControl(w http.ResponseWriter, directive string) { + w.Header().Set("Cache-Control", directive) +} diff --git a/internal/middleware/validation_middleware_generated.go b/internal/middleware/validation_middleware_generated.go new file mode 100644 index 0000000..a0dbce0 --- /dev/null +++ b/internal/middleware/validation_middleware_generated.go @@ -0,0 +1,97 @@ +/* + * Copyright © 2026 OpenCHAMI a Series of LF Projects, LLC + * + * SPDX-License-Identifier: MIT + */ + +// Code generated by Fabrica v0.4.11 (commit: aa38d7c). DO NOT EDIT. +package server + +import ( + "encoding/json" + "log" + "net/http" + + "github.com/openchami/fabrica/pkg/validation" +) + +// ValidationMode defines how validation failures are handled +// Configured in .fabrica.yaml: strict +const ValidationMode = "strict" // strict, warn, disabled + +// ValidationMiddleware validates resources before processing +// +// Validation modes: +// - strict: Return 400 on validation failures (production) +// - warn: Log validation failures but continue (development) +// - disabled: Skip validation entirely (not recommended) +func ValidationMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Skip if disabled + if ValidationMode == "disabled" { + next.ServeHTTP(w, r) + return + } + + // Only validate on POST/PUT/PATCH + if r.Method != http.MethodPost && + r.Method != http.MethodPut && + r.Method != http.MethodPatch { + next.ServeHTTP(w, r) + return + } + + // Validation happens in handlers using ValidateAndRespond() + // This middleware sets up the validation context + next.ServeHTTP(w, r) + }) +} + +// ValidateAndRespond validates a resource and handles the response based on mode +// +// Returns: +// - true if validation passed or mode is warn +// - false if validation failed in strict mode (response already sent) +// +// Usage in handlers: +// +// if !ValidateAndRespond(w, r, resource) { +// return // Response already sent +// } +func ValidateAndRespond(w http.ResponseWriter, r *http.Request, resource interface{}) bool { + if err := validation.ValidateResource(resource); err != nil { + if ValidationMode == "strict" { + // Return 400 Bad Request + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]interface{}{ + "error": "Validation failed", + "details": err.Error(), + }) + return false + } else if ValidationMode == "warn" { + // Log but continue + log.Printf("WARN: Validation failed for %T: %v", resource, err) + return true + } + } + + return true +} + +// ValidationError represents a structured validation error +type ValidationError struct { + Field string `json:"field"` + Message string `json:"message"` +} + +// FormatValidationErrors converts validation errors to structured format +func FormatValidationErrors(err error) []ValidationError { + // This is a simplified version - in practice, parse validator errors + return []ValidationError{ + { + Field: "unknown", + Message: err.Error(), + }, + } +} diff --git a/internal/middleware/versioning_middleware_generated.go b/internal/middleware/versioning_middleware_generated.go new file mode 100644 index 0000000..2346355 --- /dev/null +++ b/internal/middleware/versioning_middleware_generated.go @@ -0,0 +1,174 @@ +/* + * Copyright © 2026 OpenCHAMI a Series of LF Projects, LLC + * + * SPDX-License-Identifier: MIT + */ + +// Code generated by Fabrica v0.4.11 (commit: aa38d7c). DO NOT EDIT. +package server + +import ( + "encoding/json" + "fmt" + "net/http" + "regexp" + "strconv" + "strings" + + "github.com/openchami/fabrica/pkg/versioning" +) + +// VersionStrategy defines how API versions are negotiated +// Configured in .fabrica.yaml: header +const VersionStrategy = "header" // header, url, both + +// SupportedVersions lists all API versions supported by this server +var SupportedVersions = []int{1} + +// DefaultVersion is the version used when none is specified +const DefaultVersion = 1 + +// VersioningMiddleware handles API version negotiation +// +// Strategies: +// - header: Uses Accept header (application/vnd.myapp.v1+json) +// - url: Uses URL prefix (/v1/resources) +// - both: Supports both strategies, header takes precedence +// +// Sets request context with: +// - "api-version": int (requested version) +func VersioningMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var version int + var err error + + switch VersionStrategy { + case "header": + version, err = extractVersionFromHeader(r) + case "url": + version, err = extractVersionFromURL(r) + case "both": + // Try header first, fall back to URL + version, err = extractVersionFromHeader(r) + if err != nil || version == 0 { + version, err = extractVersionFromURL(r) + } + default: + version = DefaultVersion + } + + if err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]interface{}{ + "error": "Invalid API version", + "message": err.Error(), + "supported_versions": SupportedVersions, + }) + return + } + + // Use default if no version specified + if version == 0 { + version = DefaultVersion + } + + // Validate version is supported + if !isVersionSupported(version) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotAcceptable) + json.NewEncoder(w).Encode(map[string]interface{}{ + "error": "Unsupported API version", + "requested_version": version, + "supported_versions": SupportedVersions, + }) + return + } + + // Note: Version context is handled by versioning middleware + // The versioning.VersionNegotiationMiddleware should be used instead + + // Set version header in response + w.Header().Set("X-API-Version", fmt.Sprintf("%d", version)) + + next.ServeHTTP(w, r) + }) +} + +// extractVersionFromHeader parses version from Accept header +// Format: Accept: application/vnd.myapp.v1+json +func extractVersionFromHeader(r *http.Request) (int, error) { + accept := r.Header.Get("Accept") + if accept == "" { + return 0, nil // No version specified + } + + // Parse Accept header for version + // Matches: application/vnd.*.v+json + re := regexp.MustCompile(`application/vnd\.[^.]+\.v(\d+)\+json`) + matches := re.FindStringSubmatch(accept) + + if len(matches) < 2 { + // Try simple application/json (use default) + if strings.Contains(accept, "application/json") { + return 0, nil + } + return 0, fmt.Errorf("invalid Accept header format: %s", accept) + } + + version, err := strconv.Atoi(matches[1]) + if err != nil { + return 0, fmt.Errorf("invalid version number: %s", matches[1]) + } + + return version, nil +} + +// extractVersionFromURL parses version from URL path +// Format: /v1/resources or /api/v1/resources +func extractVersionFromURL(r *http.Request) (int, error) { + path := r.URL.Path + + // Match /v/ or /api/v/ + re := regexp.MustCompile(`^(?:/api)?/v(\d+)/`) + matches := re.FindStringSubmatch(path) + + if len(matches) < 2 { + return 0, nil // No version in URL + } + + version, err := strconv.Atoi(matches[1]) + if err != nil { + return 0, fmt.Errorf("invalid version number in URL: %s", matches[1]) + } + + return version, nil +} + +// isVersionSupported checks if the requested version is supported +func isVersionSupported(version int) bool { + for _, v := range SupportedVersions { + if v == version { + return true + } + } + return false +} + +// GetVersionFromContext retrieves the API version from request context +func GetVersionFromContext(r *http.Request) string { + versionCtx := versioning.GetVersionContext(r.Context()) + if versionCtx != nil { + return versionCtx.ServeVersion + } + return "v1" +} + +// VersionDeprecatedWarning adds deprecation warning header +func VersionDeprecatedWarning(w http.ResponseWriter, version int, sunsetDate string) { + w.Header().Set("X-API-Deprecation", fmt.Sprintf("Version %d is deprecated", version)) + if sunsetDate != "" { + w.Header().Set("Sunset", sunsetDate) + } + w.Header().Set("Link", `; rel="deprecation"`) +} diff --git a/internal/storage/storage_generated.go b/internal/storage/storage_generated.go new file mode 100644 index 0000000..15a45ff --- /dev/null +++ b/internal/storage/storage_generated.go @@ -0,0 +1,365 @@ +// Code generated by Fabrica v0.4.11 (commit: aa38d7c). DO NOT EDIT. +// Copyright © 2026 OpenCHAMI a Series of LF Projects, LLC +// +// SPDX-License-Identifier: MIT +// +// This file provides storage convenience functions for your resources. +// It uses the fabrica storage backend system. +// +// To modify storage behavior: +// 1. Edit pkg/codegen/templates/storage.go.tmpl +// 2. Run 'fabrica generate' to regenerate +// +// To change storage backend: +// 1. Call storage.Init() with a different backend in main.go +// 2. Options: FileBackend, EntBackend (database), or custom backend +package storage + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/openchami/fabrica/pkg/reconcile" + fabricaStorage "github.com/openchami/fabrica/pkg/storage" + + "github.com/openchami/github-actions-testbed/apis/testbed.openchami.io/v1" +) + +// Backend is the storage backend used by all storage operations. +// Initialize this in your main.go before using any storage functions. +// +// Example: +// +// backend, err := fabricaStorage.NewFileBackend("./data") +// if err != nil { +// log.Fatal(err) +// } +// storage.Init(backend) +var Backend fabricaStorage.StorageBackend + +// Init initializes the storage backend. +// This must be called before using any storage functions. +func Init(backend fabricaStorage.StorageBackend) { + Backend = backend +} + +// InitFileBackend is a convenience function to initialize file-based storage. +// It creates the directory if it doesn't exist. +func InitFileBackend(dataDir string) error { + backend, err := fabricaStorage.NewFileBackend(dataDir) + if err != nil { + return fmt.Errorf("failed to create file backend: %w", err) + } + Backend = backend + return nil +} + +// ensureBackend panics if Backend is not initialized. +// This is called by all storage functions to ensure proper initialization. +func ensureBackend() { + if Backend == nil { + panic("storage backend not initialized: call storage.Init() or storage.InitFileBackend() in main.go") + } +} + +// Widget storage operations + +// LoadAllWidgets retrieves all Widget resources. +// +// Parameters: +// - ctx: Context for cancellation and timeouts +// +// Returns: +// - []*v1.Widget: Slice of Widget resources +// - error: Any error that occurred during loading +func LoadAllWidgets(ctx context.Context) ([]*v1.Widget, error) { + ensureBackend() + + rawData, err := Backend.LoadAll(ctx, "Widget") + if err != nil { + return nil, fmt.Errorf("failed to load all widgets: %w", err) + } + + widgets := make([]*v1.Widget, 0, len(rawData)) + for _, raw := range rawData { + widget := &v1.Widget{} + if err := json.Unmarshal(raw, widget); err != nil { + return nil, fmt.Errorf("failed to unmarshal Widget: %w", err) + } + widgets = append(widgets, widget) + } + + return widgets, nil +} + +// LoadWidget retrieves a single Widget resource by UID. +// +// Parameters: +// - ctx: Context for cancellation and timeouts +// - uid: Unique identifier of the Widget resource +// +// Returns: +// - *v1.Widget: The Widget resource +// - error: fabricaStorage.ErrNotFound if resource doesn't exist, other errors for failures +func LoadWidget(ctx context.Context, uid string) (*v1.Widget, error) { + ensureBackend() + + rawData, err := Backend.Load(ctx, "Widget", uid) + if err != nil { + return nil, fmt.Errorf("failed to load Widget %s: %w", uid, err) + } + + widget := &v1.Widget{} + if err := json.Unmarshal(rawData, widget); err != nil { + return nil, fmt.Errorf("failed to unmarshal Widget: %w", err) + } + + return widget, nil +} + +// SaveWidget stores a Widget resource. +// +// Parameters: +// - ctx: Context for cancellation and timeouts +// - widget: The Widget resource to save +// +// Returns: +// - error: Any error that occurred during saving +func SaveWidget(ctx context.Context, widget *v1.Widget) error { + ensureBackend() + + data, err := json.Marshal(widget) + if err != nil { + return fmt.Errorf("failed to marshal Widget: %w", err) + } + + if err := Backend.Save(ctx, "Widget", widget.Metadata.UID, data); err != nil { + return fmt.Errorf("failed to save Widget: %w", err) + } + + return nil +} + +// UpdateWidget updates an existing Widget resource. +// +// Parameters: +// - ctx: Context for cancellation and timeouts +// - widget: The Widget resource to update +// +// Returns: +// - error: fabricaStorage.ErrNotFound if resource doesn't exist, other errors for failures +func UpdateWidget(ctx context.Context, widget *v1.Widget) error { + ensureBackend() + + exists, err := Backend.Exists(ctx, "Widget", widget.Metadata.UID) + if err != nil { + return fmt.Errorf("failed to check Widget existence: %w", err) + } + if !exists { + return fabricaStorage.ErrNotFound + } + + data, err := json.Marshal(widget) + if err != nil { + return fmt.Errorf("failed to marshal Widget: %w", err) + } + + if err := Backend.Save(ctx, "Widget", widget.Metadata.UID, data); err != nil { + return fmt.Errorf("failed to update Widget: %w", err) + } + + return nil +} + +// DeleteWidget removes a Widget resource by UID. +// +// Parameters: +// - ctx: Context for cancellation and timeouts +// - uid: Unique identifier of the Widget resource +// +// Returns: +// - error: fabricaStorage.ErrNotFound if resource doesn't exist, other errors for failures +func DeleteWidget(ctx context.Context, uid string) error { + ensureBackend() + + if err := Backend.Delete(ctx, "Widget", uid); err != nil { + return fmt.Errorf("failed to delete Widget %s: %w", uid, err) + } + + return nil +} + +// ExistsWidget checks if a Widget resource exists. +// +// Parameters: +// - ctx: Context for cancellation and timeouts +// - uid: Unique identifier of the Widget resource +// +// Returns: +// - bool: true if the resource exists +// - error: Any error that occurred during the check +func ExistsWidget(ctx context.Context, uid string) (bool, error) { + ensureBackend() + + exists, err := Backend.Exists(ctx, "Widget", uid) + if err != nil { + return false, fmt.Errorf("failed to check Widget existence: %w", err) + } + + return exists, nil +} + +// ListWidgetUIDs returns UIDs of all Widget resources. +// +// Parameters: +// - ctx: Context for cancellation and timeouts +// +// Returns: +// - []string: Array of Widget resource UIDs +// - error: Any error that occurred during listing +func ListWidgetUIDs(ctx context.Context) ([]string, error) { + ensureBackend() + + uids, err := Backend.List(ctx, "Widget") + if err != nil { + return nil, fmt.Errorf("failed to list Widget UIDs: %w", err) + } + + return uids, nil +} + +// StorageClient wraps a StorageBackend to implement reconcile.ClientInterface. +// +// This adapter allows reconcilers to use the storage backend through a +// standard interface that provides typed resource access. +type StorageClient struct { + backend fabricaStorage.StorageBackend +} + +// Compile-time check that StorageClient implements reconcile.ClientInterface +var _ reconcile.ClientInterface = (*StorageClient)(nil) + +// NewStorageClient creates a new storage client that wraps the configured backend. +// +// This is used by the reconciliation system to provide reconcilers with +// access to resource storage. +// +// Returns: +// - *StorageClient: Client that implements reconcile.ClientInterface +func NewStorageClient() *StorageClient { + ensureBackend() + return &StorageClient{backend: Backend} +} + +// --- Version snapshot helpers (file backend only) --- + +// Get retrieves a resource by kind and UID. +// +// Parameters: +// - ctx: Context for cancellation and timeouts +// - kind: Resource kind (e.g., "Device", "Rack") +// - uid: Unique identifier +// +// Returns: +// - interface{}: The resource (type-specific) +// - error: Any error that occurred +func (c *StorageClient) Get(ctx context.Context, kind, uid string) (interface{}, error) { + rawData, err := c.backend.Load(ctx, kind, uid) + if err != nil { + return nil, err + } + + // Unmarshal based on kind + switch kind { + case "Widget": + var resource v1.Widget + if err := json.Unmarshal(rawData, &resource); err != nil { + return nil, fmt.Errorf("failed to unmarshal Widget: %w", err) + } + return &resource, nil + default: + return nil, fmt.Errorf("unknown resource kind: %s", kind) + } +} + +// List retrieves all resources of a given kind. +// +// Parameters: +// - ctx: Context for cancellation and timeouts +// - kind: Resource kind (e.g., "Device", "Rack") +// +// Returns: +// - []interface{}: Slice of resources +// - error: Any error that occurred +func (c *StorageClient) List(ctx context.Context, kind string) ([]interface{}, error) { + rawData, err := c.backend.LoadAll(ctx, kind) + if err != nil { + return nil, err + } + + // Unmarshal based on kind + switch kind { + case "Widget": + result := make([]interface{}, 0, len(rawData)) + for _, raw := range rawData { + var resource v1.Widget + if err := json.Unmarshal(raw, &resource); err != nil { + return nil, fmt.Errorf("failed to unmarshal Widget: %w", err) + } + result = append(result, &resource) + } + return result, nil + default: + return nil, fmt.Errorf("unknown resource kind: %s", kind) + } +} + +// Update updates an existing resource. +// +// Parameters: +// - ctx: Context for cancellation and timeouts +// - resource: The resource to update +// +// Returns: +// - error: Any error that occurred +func (c *StorageClient) Update(ctx context.Context, resource interface{}) error { + data, err := json.Marshal(resource) + if err != nil { + return fmt.Errorf("failed to marshal resource: %w", err) + } + + // Extract kind and UID based on type + switch res := resource.(type) { + case *v1.Widget: + return c.backend.Save(ctx, "Widget", res.Metadata.UID, data) + default: + return fmt.Errorf("unknown resource type: %T", resource) + } +} + +// Create creates a new resource. +// +// Parameters: +// - ctx: Context for cancellation and timeouts +// - resource: The resource to create +// +// Returns: +// - error: Any error that occurred +func (c *StorageClient) Create(ctx context.Context, resource interface{}) error { + // For storage backend, Create is the same as Update (Save handles both) + return c.Update(ctx, resource) +} + +// Delete deletes a resource by kind and UID. +// +// Parameters: +// - ctx: Context for cancellation and timeouts +// - kind: Resource kind +// - uid: Unique identifier +// +// Returns: +// - error: Any error that occurred +func (c *StorageClient) Delete(ctx context.Context, kind, uid string) error { + return c.backend.Delete(ctx, kind, uid) +} diff --git a/pkg/apiversion/registry_generated.go b/pkg/apiversion/registry_generated.go new file mode 100644 index 0000000..4d273d2 --- /dev/null +++ b/pkg/apiversion/registry_generated.go @@ -0,0 +1,39 @@ +// Code generated by Fabrica v0.4.11 (commit: aa38d7c). DO NOT EDIT. +// Template: apiversion/register.gotmpl +// Generated at: 2026-10-20T15:31:55Z +// SPDX-FileCopyrightText: Copyright © 2026 OpenCHAMI a Series of LF Projects, LLC +// SPDX-License-Identifier: MIT +// +// + +package apiversion + +import ( + "github.com/openchami/fabrica/pkg/apiversion" + "github.com/openchami/fabrica/pkg/versioning" +) + +// Registry is the global API version registry +var Registry *apiversion.Registry + +func init() { + Registry = apiversion.NewRegistry() + Registry.RegisterGroup(apiversion.Group{ + Name: "testbed.openchami.io", + StorageVersion: "v1", + Spokes: []string{"v1"}, + }) + if err := versioning.GlobalVersionRegistry.RegisterVersion("Widget", "v1", versioning.ResourceTypeInfo{ + Metadata: versioning.SchemaVersion{ + Version: "v1", + IsDefault: true, + }, + }); err != nil { + panic(err) + } +} + +// GetRegistry returns the global version registry +func GetRegistry() *apiversion.Registry { + return Registry +} diff --git a/pkg/client/client_generated.go b/pkg/client/client_generated.go new file mode 100644 index 0000000..3537828 --- /dev/null +++ b/pkg/client/client_generated.go @@ -0,0 +1,631 @@ +// Code generated by Fabrica v0.4.11 (commit: aa38d7c). DO NOT EDIT. +// Copyright © 2026 OpenCHAMI a Series of LF Projects, LLC +// +// SPDX-License-Identifier: MIT +// +// This file provides a Go HTTP client library for the inventory API. +// Generated from: pkg/codegen/templates/client.go.tmpl +// +// To modify the client: +// 1. Edit the template file: pkg/codegen/templates/client.go.tmpl +// 2. Run 'fabrica generate' to regenerate +// 3. Do NOT edit this file directly - changes will be lost +// +// Generated client methods for each resource: +// - GetHealth(ctx) - Get service health status +// - GetResources(ctx) - List all resources +// - GetResource(ctx, uid) - Get specific resource by UID +// - CreateResource(ctx, req) - Create new resource +// - UpdateResource(ctx, uid, req) - Update existing resource spec +// - PatchResource(ctx, uid, patchData, contentType) - Patch existing resource spec +// - UpdateResourceStatus(ctx, uid, status) - Update resource status only +// - PatchResourceStatus(ctx, uid, patchData) - Patch resource status only +// - DeleteResource(ctx, uid) - Delete resource +// +// Usage example: +// client, err := client.NewClient("http://localhost:8080", nil, client.DefaultLogger()) +// if err != nil { +// log.Fatal(err) +// } +// +// bmcs, err := client.GetBMCs(context.Background()) +// if err != nil { +// log.Fatal(err) +// } +// +// To add authentication: +// 1. Create custom http.Client with auth transport +// 2. Pass it to NewClient: +// httpClient := &http.Client{ +// Transport: &AuthTransport{Token: "your-token"}, +// } +// client, _ := client.NewClient(baseURL, httpClient, client.DefaultLogger) +// +// To add custom headers: +// 1. Modify doRequest method to accept header options +// 2. Or wrap http.Client with custom RoundTripper +// +// To handle retries: +// 1. Wrap http.Client with retry transport +// 2. Use github.com/hashicorp/go-retryablehttp or similar +// + +package client + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path" + "strings" + + "github.com/openchami/fabrica/pkg/fabrica" + "github.com/rs/zerolog" + "github.com/spf13/cobra" + + "github.com/openchami/github-actions-testbed/apis/testbed.openchami.io/v1" +) + +// Client provides access to the inventory API +type Client struct { + baseURL *url.URL + httpClient *http.Client + logger zerolog.Logger + version string // Optional API version for Accept/Content-Type headers + bearerToken string // Optional JWT bearer token for Authorization header + showToken bool // Whether access tokens are shown in full in debug logs +} + +// tokenPrefixLen is the number of leading characters of a token to keep when +// truncating it for logs. +const tokenPrefixLen = 6 + +// RedactToken returns token unchanged if show is true. Otherwise, it +// returns a truncated form of the token that still indicates a token exists. +// Tokens that are tokenPrefixLen characters or shorter are fully masked. +func RedactToken(token string, show bool) string { + if show || token == "" { + return token + } + if len(token) <= tokenPrefixLen { + return "..." + } + return token[:tokenPrefixLen] + "..." +} + +// redactAuthHeaderValues returns a copy of Authorization header values with +// their token portions redacted unless show is true. +func redactAuthHeaderValues(vals []string, show bool) []string { + if show { + return vals + } + out := make([]string, len(vals)) + for i, v := range vals { + const bearerPrefix = "Bearer " + if strings.HasPrefix(v, bearerPrefix) { + out[i] = bearerPrefix + RedactToken(strings.TrimPrefix(v, bearerPrefix), show) + } else { + out[i] = RedactToken(v, show) + } + } + return out +} + +// isAuthorizationHeader reports whether key is the Authorization header. +func isAuthorizationHeader(key string) bool { + return http.CanonicalHeaderKey(key) == "Authorization" +} + +// ErrorResponse represents an API error response +type ErrorResponse struct { + Error string `json:"error"` +} + +// HealthResponse represents a generic service health payload. +// Services may return additional fields beyond the common ones. +type HealthResponse map[string]interface{} + +// NewClient creates a new API client +func NewClient(baseURL string, httpClient *http.Client, logger zerolog.Logger) (*Client, error) { + if httpClient == nil { + httpClient = http.DefaultClient + } + + u, err := url.Parse(baseURL) + if err != nil { + return nil, fmt.Errorf("invalid base URL: %w", err) + } + + return &Client{ + baseURL: u, + httpClient: httpClient, + logger: logger, + }, nil +} + +// NewClientWithBearerToken creates a new API client configured with a JWT bearer token. +func NewClientWithBearerToken(baseURL, bearerToken string, httpClient *http.Client, logger zerolog.Logger) (*Client, error) { + c, err := NewClient(baseURL, httpClient, logger) + if err != nil { + return nil, err + } + return c.WithBearerToken(bearerToken), nil +} + +// WithVersion returns a new client configured to use a specific API version +func (c *Client) WithVersion(version string) *Client { + return &Client{ + baseURL: c.baseURL, + httpClient: c.httpClient, + version: version, + bearerToken: c.bearerToken, + showToken: c.showToken, + logger: c.logger, + } +} + +// WithBearerToken returns a new client configured to send Authorization: Bearer . +func (c *Client) WithBearerToken(token string) *Client { + return &Client{ + baseURL: c.baseURL, + httpClient: c.httpClient, + version: c.version, + bearerToken: token, + showToken: c.showToken, + logger: c.logger, + } +} + +// WithShowToken returns a new client configured to show full access tokens in +// debug logs when show is true. Tokens are truncated by default. +func (c *Client) WithShowToken(show bool) *Client { + return &Client{ + baseURL: c.baseURL, + httpClient: c.httpClient, + version: c.version, + bearerToken: c.bearerToken, + showToken: show, + logger: c.logger, + } +} + +// doRequest performs an HTTP request and handles the response +func (c *Client) doRequest(ctx context.Context, method, endpoint string, body interface{}, result interface{}) error { + var reqBody io.Reader + if body != nil { + jsonData, err := json.Marshal(body) + if err != nil { + return fmt.Errorf("failed to marshal request body: %w", err) + } + reqBody = bytes.NewBuffer(jsonData) + } + + u := *c.baseURL + u.Path = path.Join(u.Path, endpoint) + + req, err := http.NewRequestWithContext(ctx, method, u.String(), reqBody) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + // Set Content-Type and Accept headers with optional version + contentType := "application/json" + acceptType := "application/json" + if c.version != "" { + contentType = fmt.Sprintf("application/json;version=%s", c.version) + acceptType = fmt.Sprintf("application/json;version=%s", c.version) + } + + if body != nil { + req.Header.Set("Content-Type", contentType) + } + req.Header.Set("Accept", acceptType) + if c.bearerToken != "" { + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.bearerToken)) + } + + // Debug info for request + c.logger.Debug().Msgf("%s: %s", method, u.String()) + if len(req.Header) > 0 { + c.logger.Debug().Msg("Request headers:") + for k, v := range req.Header { + if isAuthorizationHeader(k) { + c.logger.Debug().Msgf(" %s: %s", k, redactAuthHeaderValues(v, c.showToken)) + } else { + c.logger.Debug().Msgf(" %s: %s", k, v) + } + } + } else { + c.logger.Debug().Msg("No headers in request") + } + if req.ContentLength > 0 { + var reqBodyCopy bytes.Buffer + reqBodyReader := io.TeeReader(req.Body, &reqBodyCopy) + reqBodyBytes, err := io.ReadAll(reqBodyReader) + if err != nil { + c.logger.Error().Err(err).Msg("failed to read body for debug message") + } + c.logger.Debug().Msg("Request body:") + c.logger.Debug().Msgf("%s", string(reqBodyBytes)) + req.Body = io.NopCloser(bytes.NewReader(reqBodyBytes)) + } else { + c.logger.Debug().Msg("No body in request") + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + // Debug info for response + if resp != nil { + c.logger.Debug().Msg("Response status: " + resp.Status) + if len(resp.Header) > 0 { + c.logger.Debug().Msg("Response headers:") + for k, v := range resp.Header { + if isAuthorizationHeader(k) { + c.logger.Debug().Msgf(" %s: %s", k, redactAuthHeaderValues(v, c.showToken)) + } else { + c.logger.Debug().Msgf(" %s: %s", k, v) + } + } + } else { + c.logger.Debug().Msg("No headers in response") + } + respBodyLen := resp.ContentLength + if respBodyLen > 0 { + var respBodyCopy bytes.Buffer + respBodyReader := io.TeeReader(resp.Body, &respBodyCopy) + respBodyBytes, err := io.ReadAll(respBodyReader) + if err != nil { + c.logger.Error().Err(err).Msg("failed to read body for debug message") + } + c.logger.Debug().Msg("Response body:") + c.logger.Debug().Msgf("%s", string(respBodyBytes)) + resp.Body = io.NopCloser(bytes.NewReader(respBodyBytes)) + } else { + c.logger.Debug().Msg("No body in response") + } + } else { + c.logger.Debug().Msg("Response was nil") + } + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("failed to read response body: %w", err) + } + + if resp.StatusCode >= 400 { + var errorResp ErrorResponse + if err := json.Unmarshal(respBody, &errorResp); err != nil { + return fmt.Errorf("HTTP error %d: %s", resp.StatusCode, string(respBody)) + } + return fmt.Errorf("API error (%d): %s", resp.StatusCode, errorResp.Error) + } + + if result != nil { + if err := json.Unmarshal(respBody, result); err != nil { + return fmt.Errorf("failed to unmarshal response: %w", err) + } + } + + return nil +} + +// doPatchRequest performs a PATCH request with custom content type +func (c *Client) doPatchRequest(ctx context.Context, endpoint string, patchData []byte, contentType string, result interface{}) error { + u := *c.baseURL + u.Path = path.Join(u.Path, endpoint) + + req, err := http.NewRequestWithContext(ctx, "PATCH", u.String(), bytes.NewBuffer(patchData)) + if err != nil { + return fmt.Errorf("failed to create patch request: %w", err) + } + + // Set patch-specific Content-Type + req.Header.Set("Content-Type", contentType) + + // Set Accept header with optional version + acceptType := "application/json" + if c.version != "" { + acceptType = fmt.Sprintf("application/json;version=%s", c.version) + } + req.Header.Set("Accept", acceptType) + if c.bearerToken != "" { + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.bearerToken)) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("patch request failed: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("failed to read patch response body: %w", err) + } + + if resp.StatusCode >= 400 { + var errorResp ErrorResponse + if err := json.Unmarshal(respBody, &errorResp); err != nil { + return fmt.Errorf("PATCH HTTP error %d: %s", resp.StatusCode, string(respBody)) + } + return fmt.Errorf("PATCH API error (%d): %s", resp.StatusCode, errorResp.Error) + } + + if result != nil { + if err := json.Unmarshal(respBody, result); err != nil { + return fmt.Errorf("failed to unmarshal patch response: %w", err) + } + } + + return nil +} + +// GetHealth retrieves the service health endpoint response. +func (c *Client) GetHealth(ctx context.Context) (HealthResponse, error) { + result := HealthResponse{} + if err := c.doRequest(ctx, "GET", "health", nil, &result); err != nil { + return nil, err + } + return result, nil +} + +// GetWidgets retrieves all widgets +func (c *Client) GetWidgets(ctx context.Context) ([]v1.Widget, error) { + var response []v1.Widget + if err := c.doRequest(ctx, "GET", "/widgets", nil, &response); err != nil { + return nil, err + } + return response, nil +} + +// GetWidget retrieves a specific Widget by UID +func (c *Client) GetWidget(ctx context.Context, uid string) (*v1.Widget, error) { + var result v1.Widget + endpoint := fmt.Sprintf("/widgets/%s", uid) + if err := c.doRequest(ctx, "GET", endpoint, nil, &result); err != nil { + return nil, err + } + return &result, nil +} + +// CreateWidgetSimple creates a new Widget with minimal boilerplate (Simple API) +// +// This is the recommended method for most use cases. It only requires a name and spec, +// and the client automatically handles the resource envelope (APIVersion, Kind, Metadata). +// +// Use this when: +// - You only need to set the resource name and spec +// - You don't need custom labels or annotations +// - You want the simplest possible API +// +// For advanced use cases (labels, annotations, custom metadata), use CreateWidget instead. +// +// Example: +// +// spec := v1.WidgetSpec{ +// // ... your spec fields +// } +// resource, err := client.CreateWidgetSimple(ctx, "my-resource", spec) +func (c *Client) CreateWidgetSimple(ctx context.Context, name string, spec v1.WidgetSpec) (*v1.Widget, error) { + req := CreateWidgetRequest{ + Metadata: fabrica.Metadata{ + Name: name, + }, + Spec: spec, + } + return c.CreateWidget(ctx, req) +} + +// CreateWidget creates a new Widget (Advanced API) +// +// This method provides full control over the resource envelope, including labels and annotations. +// Most users should use CreateWidgetSimple instead. +// +// Use this when: +// - You need to set labels or annotations +// - You need full control over metadata fields +// - You're migrating from code that already constructs requests +// +// Example: +// +// req := CreateWidgetRequest{ +// Metadata: fabrica.Metadata{Name: "my-resource"}, +// Spec: spec, +// Labels: map[string]string{"env": "prod"}, +// } +// resource, err := client.CreateWidget(ctx, req) +func (c *Client) CreateWidget(ctx context.Context, req CreateWidgetRequest) (*v1.Widget, error) { + var result v1.Widget + if err := c.doRequest(ctx, "POST", "/widgets", req, &result); err != nil { + return nil, err + } + return &result, nil +} + +// UpdateWidgetSimple updates an existing Widget's spec (Simple API) +// +// This is the recommended method for most update operations. It only requires the UID +// and the new spec, preserving all existing metadata. +// +// Use this when: +// - You only need to update the spec +// - You want to preserve existing labels and annotations +// - You want the simplest possible API +// +// For advanced use cases (updating labels/annotations), use UpdateWidget instead. +// +// Example: +// +// spec := v1.WidgetSpec{ +// // ... updated spec fields +// } +// resource, err := client.UpdateWidgetSimple(ctx, uid, spec) +func (c *Client) UpdateWidgetSimple(ctx context.Context, uid string, spec v1.WidgetSpec) (*v1.Widget, error) { + req := UpdateWidgetRequest{ + Spec: spec, + } + return c.UpdateWidget(ctx, uid, req) +} + +// UpdateWidget updates an existing Widget (Advanced API) +// +// This method provides full control over what gets updated, including metadata fields. +// Most users should use UpdateWidgetSimple instead. +// +// Use this when: +// - You need to update labels or annotations +// - You need to update the resource name +// - You need full control over what fields get updated +// +// Example: +// +// req := UpdateWidgetRequest{ +// Spec: spec, +// Labels: map[string]string{"env": "staging"}, +// } +// resource, err := client.UpdateWidget(ctx, uid, req) +func (c *Client) UpdateWidget(ctx context.Context, uid string, req UpdateWidgetRequest) (*v1.Widget, error) { + var result v1.Widget + endpoint := fmt.Sprintf("/widgets/%s", uid) + if err := c.doRequest(ctx, "PUT", endpoint, req, &result); err != nil { + return nil, err + } + return &result, nil +} + +// PatchWidget patches an existing Widget spec with the specified patch data and content type +func (c *Client) PatchWidget(ctx context.Context, uid string, patchData []byte, contentType string) (*v1.Widget, error) { + var result v1.Widget + endpoint := fmt.Sprintf("/widgets/%s", uid) + if err := c.doPatchRequest(ctx, endpoint, patchData, contentType, &result); err != nil { + return nil, err + } + return &result, nil +} + +// UpdateWidgetStatus updates only the status of an existing Widget +// This method is intended for controllers, reconcilers, and monitoring systems. +// It preserves the spec and only updates the status portion of the resource. +func (c *Client) UpdateWidgetStatus(ctx context.Context, uid string, status v1.WidgetStatus) (*v1.Widget, error) { + var result v1.Widget + endpoint := fmt.Sprintf("/widgets/%s/status", uid) + if err := c.doRequest(ctx, "PUT", endpoint, status, &result); err != nil { + return nil, err + } + return &result, nil +} + +// PatchWidgetStatus patches only the status of an existing Widget +// Supports JSON Merge Patch by default. Use PatchWidgetStatusWithType for other patch formats. +func (c *Client) PatchWidgetStatus(ctx context.Context, uid string, patchData []byte) (*v1.Widget, error) { + return c.PatchWidgetStatusWithType(ctx, uid, patchData, "application/merge-patch+json") +} + +// PatchWidgetStatusWithType patches status with a specific patch content type +// Supported types: application/merge-patch+json, application/json-patch+json, application/fabrica-patch+json +func (c *Client) PatchWidgetStatusWithType(ctx context.Context, uid string, patchData []byte, contentType string) (*v1.Widget, error) { + var result v1.Widget + endpoint := fmt.Sprintf("/widgets/%s/status", uid) + if err := c.doPatchRequest(ctx, endpoint, patchData, contentType, &result); err != nil { + return nil, err + } + return &result, nil +} + +// DeleteWidget deletes a Widget by UID +func (c *Client) DeleteWidget(ctx context.Context, uid string) error { + endpoint := fmt.Sprintf("/widgets/%s", uid) + var response DeleteResponse + if err := c.doRequest(ctx, "DELETE", endpoint, nil, &response); err != nil { + return err + } + return nil +} + +// LogLevel represents a valid log level +type LogLevel string + +const ( + LogLevelInfo LogLevel = "info" + LogLevelWarning LogLevel = "warning" + LogLevelDebug LogLevel = "debug" +) + +var ( + LogLevelHelp = map[string]string{ + string(LogLevelInfo): "Info log level", + string(LogLevelWarning): "Warning log level", + string(LogLevelDebug): "Debug log level", + } +) + +func (ll LogLevel) String() string { + return string(ll) +} + +func (ll *LogLevel) Set(v string) error { + switch LogLevel(v) { + case LogLevelInfo, + LogLevelWarning, + LogLevelDebug: + *ll = LogLevel(v) + return nil + default: + return fmt.Errorf("must be one of %v", []LogLevel{ + LogLevelInfo, + LogLevelWarning, + LogLevelDebug, + }) + } +} + +func (ll LogLevel) Type() string { + return "LogLevel" +} + +// CompletionLogLevel is the cobra completion function for the --log-level +// flag. +func CompletionLogLevel(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + var helpSlice []string + for k, v := range LogLevelHelp { + helpSlice = append(helpSlice, fmt.Sprintf("%s\t%s", k, v)) + } + return helpSlice, cobra.ShellCompDirectiveDefault +} + +func DefaultLogger() zerolog.Logger { + cw := zerolog.ConsoleWriter{ + Out: os.Stderr, + PartsExclude: []string{zerolog.TimestampFieldName}, + } + return zerolog.New(cw).Level(zerolog.WarnLevel) +} + +func NewLogger(level LogLevel) (zerolog.Logger, error) { + cw := zerolog.ConsoleWriter{ + Out: os.Stderr, + PartsExclude: []string{zerolog.TimestampFieldName}, + } + l := zerolog.New(cw) + var ll zerolog.Level + switch level { + case LogLevelWarning: + ll = zerolog.WarnLevel + case LogLevelInfo: + ll = zerolog.InfoLevel + case LogLevelDebug: + ll = zerolog.DebugLevel + default: + return DefaultLogger(), fmt.Errorf("invalid log level: %s", level) + } + return l.Level(ll), nil +} diff --git a/pkg/client/models_generated.go b/pkg/client/models_generated.go new file mode 100644 index 0000000..c6a357a --- /dev/null +++ b/pkg/client/models_generated.go @@ -0,0 +1,68 @@ +// Copyright © 2026 OpenCHAMI a Series of LF Projects, LLC +// +// SPDX-License-Identifier: MIT +// +// This file contains client-side request/response models. +// Generated from: pkg/codegen/templates/client-models.go.tmpl +// +// To modify client models: +// 1. Edit the template file: pkg/codegen/templates/client-models.go.tmpl +// 2. Run 'fabrica generate' to regenerate +// 3. Do NOT edit this file directly - changes will be lost +// +// DEPRECATED: This template is a legacy stub. The actual client models are +// generated in models.go.tmpl and used by both server and client. +// +// Generated request types: +// - CreateResourceRequest: Create operation with metadata +// - UpdateResourceRequest: Update operation with partial fields +// - DeleteResponse: Confirmation of deletion +// +// Note: This file contains TODO comments indicating that resource-specific +// fields should be added. Consider migrating to the unified models.go.tmpl +// approach or implementing spec field embedding. +// +// To implement full spec support: +// 1. Import resource packages +// 2. Embed ResourceSpec in request structs +// 3. Use json:",inline" tag for proper serialization +// +// To add validation: +// 1. Add validate tags (e.g., validate:"required,min=3") +// 2. Use go-playground/validator in client before sending +// + +// Package client provides generated client-side request and response models. +package client + +import ( + "github.com/openchami/fabrica/pkg/fabrica" + + "github.com/openchami/github-actions-testbed/apis/testbed.openchami.io/v1" +) + +// CreateWidgetRequest represents a request to create a Widget (Advanced API) +// Use this when you need full control over metadata fields (labels, annotations). +// For simpler usage, see CreateWidgetSimple in the client. +type CreateWidgetRequest struct { + Metadata fabrica.Metadata `json:"metadata" yaml:"metadata" validate:"required"` + Spec v1.WidgetSpec `json:"spec" yaml:"spec" validate:"required"` + Labels map[string]string `json:"labels,omitempty" yaml:"labels,omitempty"` + Annotations map[string]string `json:"annotations,omitempty" yaml:"annotations,omitempty"` +} + +// UpdateWidgetRequest represents a request to update a Widget (Advanced API) +// Use this when you need to update metadata fields along with the spec. +// For simpler usage, see UpdateWidgetSimple in the client. +type UpdateWidgetRequest struct { + Metadata fabrica.Metadata `json:"metadata,omitempty" yaml:"metadata,omitempty"` + Spec v1.WidgetSpec `json:"spec,omitempty" yaml:"spec,omitempty" validate:"omitempty"` + Labels map[string]string `json:"labels,omitempty" yaml:"labels,omitempty"` + Annotations map[string]string `json:"annotations,omitempty" yaml:"annotations,omitempty"` +} + +// DeleteResponse represents a successful deletion response +type DeleteResponse struct { + Message string `json:"message"` + UID string `json:"uid"` +} diff --git a/pkg/resources/register_generated.go b/pkg/resources/register_generated.go new file mode 100644 index 0000000..df56c1a --- /dev/null +++ b/pkg/resources/register_generated.go @@ -0,0 +1,26 @@ +// Code generated by Fabrica v0.4.11. DO NOT EDIT. +// Template: cmd/fabrica/generate.go:generateVersionedRegistrationCode +// Generated: 2026-10-20T15:31:55Z +// +// Copyright © 2026 OpenCHAMI a Series of LF Projects, LLC +// +// SPDX-License-Identifier: MIT + +package resources + +import ( + "fmt" + + "github.com/openchami/fabrica/pkg/codegen" + v1 "github.com/openchami/github-actions-testbed/apis/testbed.openchami.io/v1" +) + +// RegisterAllResources registers all discovered resources with the generator. +// This file is auto-generated. Re-run 'fabrica generate' after adding resources. +func RegisterAllResources(gen *codegen.Generator) error { + if err := gen.RegisterResource(&v1.Widget{}); err != nil { + return fmt.Errorf("failed to register Widget: %w", err) + } + + return nil +}