diff --git a/Containerfile.gitops b/Containerfile.gitops new file mode 100644 index 0000000..8a92e09 --- /dev/null +++ b/Containerfile.gitops @@ -0,0 +1,31 @@ +# Build stage +FROM registry.access.redhat.com/ubi9/go-toolset:1.25.5 AS builder + +WORKDIR /app + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +USER root +RUN CGO_ENABLED=0 GOOS=linux go build -buildvcs=false -o dcm-gitops ./cmd/dcm-gitops + +# Runtime stage +FROM registry.access.redhat.com/ubi9/ubi-minimal:latest + +WORKDIR /app + +COPY --from=builder /app/dcm-gitops . + +# Create work directory for git clones +RUN mkdir -p /data/gitops + +# OpenShift runs arbitrary UIDs in group 0; g+rwX keeps dirs usable. +RUN chown -R 1001:0 /app /data/gitops && chmod -R g+rwX /app /data/gitops + +USER 1001 + +ENV GIT_WORK_DIR=/data/gitops + +ENTRYPOINT ["./dcm-gitops"] diff --git a/Makefile b/Makefile index 11cb62a..718fc1e 100644 --- a/Makefile +++ b/Makefile @@ -33,11 +33,15 @@ include make/placement.mk include make/policy.mk include make/sp.mk include make/agent.mk +include make/gitops.mk # Same as Containerfile: static build, no CGO (Postgres in prod/compose). # For SQLite local dev use make run (go run with CGO). build: - CGO_ENABLED=0 go build -buildvcs=false -o bin/$(BINARY_NAME) ./cmd/$(BINARY_NAME) + CGO_ENABLED=1 go build -buildvcs=false -o bin/$(BINARY_NAME) ./cmd/$(BINARY_NAME) + +build-gitops: + CGO_ENABLED=0 go build -buildvcs=false -o bin/dcm-gitops ./cmd/dcm-gitops # Quick local start with SQLite (no Postgres/NATS stack required). # Uses a throwaway file under /tmp; override DB_NAME if you want a different path. @@ -112,6 +116,6 @@ test: tidy: go mod tidy -.PHONY: build run run-dev compose-up compose-up-with-providers compose-down image-build \ - clean fmt vet lint test test-catalog test-placement test-policy test-sp tidy \ +.PHONY: build build-gitops run run-dev compose-up compose-up-with-providers compose-down image-build \ + clean fmt vet lint test test-catalog test-placement test-policy test-sp test-gitops tidy \ helm-chart-sync helm-chart-verify-sync helm-chart-verify-admin-subject helm-chart-verify helm-chart-verify-schema helm-chart-lint helm-chart-template helm-chart-check diff --git a/api/gitops/v1alpha1/openapi.yaml b/api/gitops/v1alpha1/openapi.yaml new file mode 100644 index 0000000..6afd801 --- /dev/null +++ b/api/gitops/v1alpha1/openapi.yaml @@ -0,0 +1,564 @@ +openapi: 3.0.4 + +info: + title: DCM GitOps Manager API + version: v1alpha1 + description: | + The DCM GitOps Manager API provides resources for configuring Git-driven + CatalogItemInstance lifecycle management. + + ## Resources + + - **GitRepositories**: Configure connections to Git repositories containing + CatalogItemInstance YAML definitions. A separate `dcm-gitops` process polls + configured repositories and reconciles DCM state. + contact: {} + license: + name: Apache 2.0 + url: https://www.apache.org/licenses/LICENSE-2.0.html + +servers: + - url: /api/v1alpha1 + +paths: + /git-repositories: + get: + operationId: listGitRepositories + summary: List git repositories + description: | + Retrieves a paginated list of git repository configurations. + Supports standard pagination using page tokens. + parameters: + - name: page_token + in: query + required: false + schema: + type: string + description: | + Token for retrieving the next page of results. + Obtained from the next_page_token field of a previous response. + + - name: max_page_size + in: query + required: false + schema: + type: integer + format: int32 + minimum: 1 + maximum: 1000 + default: 100 + description: | + Maximum number of items to return per page. + If not specified, defaults to 100. + + responses: + '200': + description: Successful response + content: + application/json: + schema: + $ref: '#/components/schemas/GitRepositoryList' + + '400': + $ref: '#/components/responses/BadRequest' + + '401': + $ref: '#/components/responses/Unauthorized' + + '403': + $ref: '#/components/responses/Forbidden' + + '500': + $ref: '#/components/responses/InternalServerError' + + post: + operationId: createGitRepository + summary: Create a git repository + description: | + Creates a new git repository configuration. + + Supports user-specified IDs via the 'id' query parameter for idempotency. + If the ID is not provided, the server will generate one. + parameters: + - name: id + in: query + required: false + schema: + type: string + pattern: '^[a-z]([a-z0-9-]{0,61}[a-z0-9])?$' + description: | + Optional user-specified ID for the git repository. + Must follow DNS-1123 label format (lowercase alphanumeric with hyphens). + If omitted, the server generates an ID. + example: production-apps + + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/GitRepository' + + responses: + '201': + description: Git repository created successfully + content: + application/json: + schema: + $ref: '#/components/schemas/GitRepository' + + '400': + description: Invalid request body or validation error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + '401': + $ref: '#/components/responses/Unauthorized' + + '403': + $ref: '#/components/responses/Forbidden' + + '409': + $ref: '#/components/responses/AlreadyExists' + + '500': + $ref: '#/components/responses/InternalServerError' + + /git-repositories/{gitRepositoryId}: + get: + operationId: getGitRepository + summary: Get a git repository + description: | + Retrieves a single git repository configuration by its ID. + parameters: + - $ref: '#/components/parameters/GitRepositoryIdPath' + + responses: + '200': + description: Git repository found + content: + application/json: + schema: + $ref: '#/components/schemas/GitRepository' + + '401': + $ref: '#/components/responses/Unauthorized' + + '403': + $ref: '#/components/responses/Forbidden' + + '404': + $ref: '#/components/responses/NotFound' + + '500': + $ref: '#/components/responses/InternalServerError' + + put: + operationId: updateGitRepository + summary: Update a git repository + description: | + Replaces a git repository configuration. + parameters: + - $ref: '#/components/parameters/GitRepositoryIdPath' + + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/GitRepository' + + responses: + '200': + description: Git repository updated successfully + content: + application/json: + schema: + $ref: '#/components/schemas/GitRepository' + + '400': + description: Invalid request body or validation error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + '401': + $ref: '#/components/responses/Unauthorized' + + '403': + $ref: '#/components/responses/Forbidden' + + '404': + $ref: '#/components/responses/NotFound' + + '500': + $ref: '#/components/responses/InternalServerError' + + delete: + operationId: deleteGitRepository + summary: Delete a git repository + description: | + Deletes a git repository configuration. + parameters: + - $ref: '#/components/parameters/GitRepositoryIdPath' + + responses: + '204': + description: Git repository deleted successfully + + '401': + $ref: '#/components/responses/Unauthorized' + + '403': + $ref: '#/components/responses/Forbidden' + + '404': + $ref: '#/components/responses/NotFound' + + '500': + $ref: '#/components/responses/InternalServerError' + +components: + parameters: + GitRepositoryIdPath: + name: gitRepositoryId + in: path + required: true + schema: + type: string + pattern: '^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$' + description: Unique identifier for the git repository + example: production-apps + + schemas: + GitRepository: + type: object + x-aep-resource: + type: gitops-manager.dcm.io/git-repository + singular: git-repository + plural: git-repositories + patterns: + - git-repositories/{git_repository_id} + required: + - api_version + - display_name + - spec + properties: + uid: + type: string + description: | + Unique identifier for the git repository. This field is output-only and + immutable after creation. The ID can be optionally specified via + query parameter on creation; if not provided, the server generates a UUID. + + Follows AEP-122 resource ID conventions. + readOnly: true + pattern: '^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$' + minLength: 1 + maxLength: 63 + example: production-apps + + api_version: + type: string + pattern: '^v[0-9]+[a-z]+[0-9]+$' + description: | + Version of the GitRepository schema (e.g., v1alpha1). + Immutable after creation. + example: v1alpha1 + + display_name: + type: string + minLength: 1 + maxLength: 63 + description: | + User-friendly display name for the git repository configuration. + example: Production Apps + + spec: + $ref: '#/components/schemas/GitRepositorySpec' + + status: + $ref: '#/components/schemas/GitRepositoryStatus' + + path: + type: string + readOnly: true + pattern: '^git-repositories/[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$' + description: | + Resource path in the format: git-repositories/{gitRepositoryId} + example: git-repositories/production-apps + + create_time: + type: string + format: date-time + readOnly: true + description: Timestamp when the resource was created (RFC 3339) + example: '2026-06-10T10:30:00Z' + + update_time: + type: string + format: date-time + readOnly: true + description: Timestamp when the resource was last modified (RFC 3339) + example: '2026-06-10T12:45:00Z' + + GitRepositorySpec: + type: object + description: | + Specification for a git repository connection. + required: + - url + properties: + url: + type: string + minLength: 1 + description: | + Git repository URL. + example: "https://git.example.com/team/dcm-apps.git" + + ref: + type: object + description: | + Git reference to watch. + properties: + branch: + type: string + default: main + description: | + Branch to watch. + example: main + + path: + type: string + default: "." + description: | + Directory within the repo. Only YAML files directly at this + path root are read (not subdirectories). + example: "apps/production/" + + interval_seconds: + type: integer + format: int32 + minimum: 10 + default: 60 + description: | + Polling interval in seconds (minimum 10s). + example: 60 + + reconciliation: + type: object + description: | + Reconciliation behavior configuration. + properties: + retry_policy: + type: object + properties: + max_retries: + type: integer + format: int32 + minimum: 0 + default: 3 + description: | + Maximum number of retries on submit failure. + example: 3 + + backoff_seconds: + type: integer + format: int32 + minimum: 1 + default: 30 + description: | + Backoff between retries in seconds. + example: 30 + + GitRepositoryStatus: + type: object + description: | + Sync status for a git repository. + properties: + sync_state: + type: string + enum: + - PENDING + - SYNCED + - IN_PROGRESS + - ERROR + description: | + Current sync state. + - PENDING: Initial state, no sync has occurred yet. + - SYNCED: DCM state matches Git at lastSyncedCommit. + - IN_PROGRESS: Lifecycle changes from Git are being applied. + - ERROR: Sync failed (auth, network, parse errors). + example: SYNCED + + last_synced_commit: + type: string + description: | + The Git commit SHA that was last successfully synced. + example: "abc123def456" + + message: + type: string + description: | + Human-readable message about the current status. + Typically contains error details when sync_state is ERROR. + example: "All instances synced successfully" + + last_sync_time: + type: string + format: date-time + description: | + Timestamp of the last sync attempt (RFC 3339). + example: '2026-06-10T12:45:00Z' + + GitRepositoryList: + type: object + required: + - results + - next_page_token + properties: + results: + type: array + description: | + Array of git repository resources. + May be empty if no results match the query. + items: + $ref: '#/components/schemas/GitRepository' + + next_page_token: + type: string + description: | + Token for retrieving the next page of results. + Empty string indicates this is the last page. + example: eyJvZmZzZXQiOjEwMH0= + + Error: + type: object + description: | + Error response following RFC 7807 Problem Details for HTTP APIs + and AEP-193 Error Responses specification. + required: + - type + - status + - title + properties: + type: + type: string + description: | + Machine-readable error code. + enum: + - INVALID_ARGUMENT + - UNAUTHENTICATED + - NOT_FOUND + - ALREADY_EXISTS + - PERMISSION_DENIED + - FAILED_PRECONDITION + - INTERNAL + - UNAVAILABLE + example: INVALID_ARGUMENT + + status: + type: integer + format: int32 + description: HTTP status code (matches the response status) + example: 400 + + title: + type: string + description: | + Short, human-readable summary of the error type. + example: Invalid request parameters + + detail: + type: string + description: | + Human-readable explanation specific to this occurrence of the error. + example: Field 'spec.url' must not be empty + + instance: + type: string + description: | + Unique identifier for this specific error occurrence. + example: 7934df3e-4b63-429b-b0f5-b8d350ec165e + + responses: + BadRequest: + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + example: + type: INVALID_ARGUMENT + status: 400 + title: Invalid request parameters + detail: Field 'spec.url' must not be empty + instance: 7934df3e-4b63-429b-b0f5-b8d350ec165e + + Unauthorized: + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + example: + type: UNAUTHENTICATED + status: 401 + title: Authentication required + detail: Valid authentication credentials are required to access this resource + instance: 6c23de2d-3a52-428a-a605-a7d240db054d + + Forbidden: + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + example: + type: PERMISSION_DENIED + status: 403 + title: Access forbidden + detail: User does not have permission to access this resource + instance: 8a45ef4f-5c74-53ac-c1f6-c9e461fd276f + + NotFound: + description: Not Found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + example: + type: NOT_FOUND + status: 404 + title: Resource not found + detail: GitRepository 'production-apps' does not exist + instance: 9b56fg5g-6d85-64bd-d2g7-d0f572ge387g + + AlreadyExists: + description: Already Exists + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + example: + type: ALREADY_EXISTS + status: 409 + title: Resource already exists + detail: GitRepository with id 'production-apps' already exists + instance: 0c67gh6h-7e96-75ce-e3h8-e1g683hf498h + + InternalServerError: + description: Internal Server Error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + example: + type: INTERNAL + status: 500 + title: Internal server error + detail: An unexpected error occurred while processing the request + instance: 2e89ij8j-9g18-97eg-g5j0-g3i805jh610j diff --git a/api/gitops/v1alpha1/spec.gen.cfg b/api/gitops/v1alpha1/spec.gen.cfg new file mode 100644 index 0000000..3bd409f --- /dev/null +++ b/api/gitops/v1alpha1/spec.gen.cfg @@ -0,0 +1,6 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/oapi-codegen/oapi-codegen/HEAD/configuration-schema.json +package: v1alpha1 +generate: + embedded-spec: true +output-options: + skip-prune: true diff --git a/api/gitops/v1alpha1/spec.gen.go b/api/gitops/v1alpha1/spec.gen.go new file mode 100644 index 0000000..7e8b10e --- /dev/null +++ b/api/gitops/v1alpha1/spec.gen.go @@ -0,0 +1,172 @@ +// Package v1alpha1 provides primitives to interact with the openapi HTTP API. +// +// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.7.0 DO NOT EDIT. +package v1alpha1 + +import ( + "bytes" + "compress/flate" + "encoding/base64" + "fmt" + "net/url" + "path" + "strings" + + "github.com/getkin/kin-openapi/openapi3" +) + +// Base64 encoded, compressed with deflate, json marshaled OpenAPI spec. +// Stored as a slice of fixed-width chunks rather than one concatenated +// const string: with thousands of chunks the chained `+` fold is several +// times slower for the Go compiler than parsing a slice literal. +var swaggerSpec = []string{ + "7Fp7b+O2lv8qhG6BmfZKtvxM4sVi4Uk8GS8SJxsnxW0ns760eGRzKpEqSSVxB/7uC5KSbD0yTuem7WKx", + "/yUWH4fn8Tu/c8gvTsDjhDNgSjqjL06CBY5BgTD/nVN1AwmXVHGxmZJrrNb6ZwIyEDRRlDNn5Nwx+msK", + "iBJgioYUBAq5QGoNaEUVEsV8x3XgCcdJBM7ISQQnaaBX8HCSSMd1qF4r0Tu4DsOxHrQqb++4joBfUyqA", + "OCMlUnAdGawhxlZwpUDoNf77I/Z+872TT2+zP7xPX3x32Nnmv3//H985rqM2id5DKkHZytlut3p1mXAm", + "wZx9HAnAZDN5otKqJuBMAVP6T5wkEQ2wFr/9WWotfNmdTetHYRo5o7L+0CNVa0QJelM5/BuE7V4I7GZa", + "GVJhFmgB/WB4tFoP194RnAy9o0EAHvTWxx50VsPj3jrsnxxrlUmFVSqdUd8/cR1FldHyDUieigDqG2Sn", + "H1/cTMZnPy0m/5jOb+fOdl+j3wkInZHzt/bOQ9r2q2xPhODCKq3sDJnWUKa2reu8w+QGfk1Bqm9U4nsK", + "EUFvZAJBKxXRGxSnUiHGFVoCgjhRm7LCjk56fRL2wOsvhz2v3z1Zeks/HHjLY9Ib+BB0hgMoKczfKWzK", + "HnBECRJWYrQXDoXOprMfxxfTs8X45vzucjK7fQWtvcME5Uraus57LpaUEGDfqLE7CQIRDtJoaY0fACUg", + "Yiol5QwpjnAQgJRIralEIvORshKPcX8AYT/0BsFR3xv0cOAFnXDoBSfQH3ZC0j0ahiUl9nZKHNvVw+IU", + "hequJzeX0/l8ejVbnE1m08nZK+hup6yt60yZBgEczUE8gLBzvk2HY4ZSBk8JBAoIAr0S4kGQCgEEPa5p", + "BCgRXB+UspVBu8xnynrswvEJ/Xz82TtZdY69kyNYeavBZ99b9eixP/i8Hnb8z3t6HJSd0R4GSXMaK8S+", + "H95Obmbji1fQYbGT1RvKBrrOjKv3PGXkVfCvjnuFjxpcKmvuZDkYhqvByhuS44E37C+JR7qrI4/44eCo", + "u4Le8dGq5IH9BtzTa4fmAIXaZle3i/dXd7PX8L0ZV8jqZ+s6dwynas0F/Q2+VV8/GvDRy+hcaiegQIBJ", + "rTiSCAvrZzoHviyQh0G3R6BLvB4edL1+9xh7eOgPPHxEun2fLP1Bn5TU2NkL5LIgRfItdHk3G9/dfpjM", + "bqen49tXieaSErfFeiYBF+FcnmJ+RnnuRiGPIv6og/Lm/Sk6OvaP0LXgywhidGb0bJAJfbi9vUbj66m8", + "Z5gRNJ5ce52TnvV8dJMTAaSzDg0zDbTuNZYlgicgFLU0IbddVaoPaYyZp5MhXkaA4CmJMLN6zNfUBjSW", + "y3CFBYB4aLDEhLrdbseZXpQIK7Rm3xdeStzo7thl4GMBVGV6YbINuYixckZOKqgnIASzWJO4uSPW9Knt", + "ZT+igBNAb2OsgjXIDHsz69sR3+/LaBJ8IQBlqtfdbUyZghUYrMu8vrrxfM2FctG6bE+ZxjEWm5K9kF6z", + "qqCXMIrd6e0PVREucbCmDPa8yWyn1ZBtx9LYGX2sUxO3FqLuHgC6Vf7nNmRo13k/nl5MzhbXN5PTq9nZ", + "9HZ6NXPcXfoxe/w4nl6M311MnE+lw9fleaknbPfJ/kf7tfCO3Fifiml8+RkCQ55KOcdUBqV4xQldPICQ", + "1KJwWdE/2g+5VcvpyyIRegutVctFDx0cJWvc+b51z6ZxnCpjGBwqEBqx9/Bip458jgaRXbny8FEXJX/X", + "1cmnv9u/v2vyDbMqLBSNG1zklsYgFY4T9LgGlseETYKPWFqRgKC3GhR7vd5JKUScrt8dev7Q6/i3HX/U", + "80e+//O+sQhW4JmdtVkwuWLRJq/BaoISKpMIbxa2iquhjgThhYICI9EGZWORHvtM3YgCzkK6SkWjTq8L", + "ToHGtpCM8dMFsJUuVYc914kpy//tNAibNNa0BYHQnxG1CrXaGGnxvEI8CrL9pVKobisy1ibUq989f6iN", + "flk9e9AsGtMP5eWSx8/1hBIkv3yqnbJ1nZSSb28ZtNCtzkahSXw6UaYqSZXHWbRBmJF7Rp+LPHS7BjQ9", + "QwFmOjtyszOOok2e2oCgB4rv2a8piM0OlZElXGaRf0M0NOk1EfyBEiCuETFj4ytgILACiTC6u5uete7Z", + "PXtvyIe0dKLb3cWgFoWzB31ezmTViev+8FUn/t29joO+kSbk27ElwlKhmBOr1RcgTHfUH/wLCFNJDfuI", + "XgGfzOlracJ1njwMiVdQ5l0DSeolGyN8sfPLBSVbvWoSpQJHDRGud6ZslUZYVL/ukbQVVTyRXowZXoFo", + "kSBuUd6ujN5Wc9oFta2Ucl5j8KQWCV7BQvFfoCG33eqfTZwJUILCQ1636plIz9RpT4BMI6UddKIJJbI6", + "R5QRzYEhKzWoZV3G8Hpm1Z9h858PP8c///bzP/6LXn2ePF5+8P+9KaVlu9WFHQuBDbmqpILcYFrAS7wp", + "iK+N1Fx6ZKihEdGEtxWPKoh/H4rtGJmDtUA118vld2vqP8hM5hkaV8jmfrlhjIUbsiGD4LlyRPNZ8YCj", + "hYSAM5KpNsRppJzR0K+WW9c8iqx97TSd6rKZ6G1MGY3TGHV8+X3FwsMmSp2Nd0Ydv4lf7zJtJo7Tcqry", + "nFEBQdEspTnYJLyFNCCgn8aXFyikEUhEzFCdCJRxyntmcrXgXGVlMiborYZvmS5Jti6F2lF0cb6fkdvN", + "jhpwFtCIYtVIHG9K39ES1viBGopeoy5le+lY3CwSHtGggawucfALD8NGY/b8ehfRjEZLUI8ALItzuWfT", + "ytF7X7dikxFj/LTI1i1L49ZqlifjPCyNlzqthoU4ugJOlzFVKMQ0SkUVPXpfE6rBs7a1UNs2BJ+J+arZ", + "zk1oZTWILscfNXA02WkpMAsq7htjymoe/M4MrKy1c7ZsTj2j1QRORfScwAUW3N1cVDdYK5XIUVtnkVb2", + "ayvgcVsBjtskiA3BaK2ocg5Q4wrUaXEOg9ozxft8w4K8eG8CtSaN6+SykBsWHGQlWbVmspGegHQmjxO1", + "R0WqWvo9bKQGBoVkQBYBj2OqGqSz5SOy39H8wxipNVY7viRT07oLU8NJzWI1YFoGnW6PQNgfDJvkiEFK", + "vIKDvadsHMJLniqjKtvNUZlJWvfsdpPQwNDjgDOFKZNZj4FkLTPD/Iw19BzQDGByc3N1U5V5HOkcYjtO", + "MjtX6ayNPZ9i3fpZTnNRcxfScOGh68nsbDo7H6Epo4riyH5yNQkwI9dY7tr1G1Bm0vyn2enkbITOTi/t", + "eJT3kLSpsDKWmRuZT43dzKzpbHF9c3V+M5nPR+iChhBsgghQsMZsBRKFgsd2vgC0BJ1NTbvX2NOzWhoh", + "EwEa7zRDxqlau4iBeuTiF1cXHzLr6RTZKWvoZMd0XMfKbvouhTiO65jly02XYuRBkNma9mDI8241DrQn", + "13qy2pe1ys6pukokurR0FY2vp3lltGs+2/jO055WxjlVHhH0Adg9O8UKR3w1VRBPMydBUaFRy4NjYMrU", + "Un/7G8rLcKn/99APP+yjDQX5ww8jdJrtBXvMSGr8LWGlTjyZa1O2umcINcli6AWBkGqn0nUaGiMJujhU", + "gP6p0dNS9n/mdz8o4VEk9XL5kYGUN8VM/2DZAcid61k7RzQAJo3bZ3fe4wQHa0Ddlu9kOaCA9MfHxxY2", + "n1tcrNrZXNm+mJ5OZvOJ1235rbWKo71mptNsN8d1ih7Yriu1dR2eAMMJdUZOr+W3+rYxsTZ43K7VOKMv", + "zgpUExky9YWpjBO8osx0niIqVQOjLzEkDUXzNEm4UFKriREsSL6GJlapuW0zxYrh2Rmj0XnDDJgSZ+To", + "AqniKOYgu5cNH//V4uhqqT0JiI3+fOBeAZD1LHiodSDggfJUFl3qrB7RG5v6ZPfiYa+C2L9KqUXyl4Ns", + "y1Q7OgwEqFQwlIDIS7Wp7WoUjRAXZZzGjO/4/vPyafpnZJT0NyiJWNCiTmO7PbYCms/+10nm9lPlJUbX", + "919wn/ayi6d6Jd1wCTUv8lVhMh0cfStH0/KFvO295w5mSufwlPKdl57UOzypdO09eIlkTXfj5obNXmVk", + "gVOOTx06unTjsiHOT01PWUc5g8dDTdu9wE4lCG/XhpueSfRAsYmiN5S8QdWWnA5LSiBOuAIWbKwHK9vf", + "o/L5Dt0jjaKiTYc4gyawsIcol/0HwOIqaybWD/JcG/OeXaZSZVeT6Gw29zqdbg9FeAlR1lZGbyP+CCLA", + "EpDBY5bGIGhgXw2tN8kamGEH0xDxmCr1fDuSIdONfMl7q0p4U+J85U3V73xQ9ckWECDVO042f0wE5++2", + "9l+FbWvw0fkjN/9qgZZfu5QY8A5KXkWmr7zqKF89LjnZIC6Q+dXmU8gfevxJQNX3Tw7PKL+8ez14s6Fe", + "Kz7NqPbhSx0LgBE0VSln5nfZ2K2rNoDK+GNnHsCfppPvhrSbnmk2ZNH+wX6CPV6Tu/5J3tE/PKN4kvR6", + "jmFt0OAY7mGCqwlp9PUrS7TcIKpkgcplBzgH9adY3//LQDDMzfV/3I3OQTX6UJI2+lAS4eCbIOPOXNb9", + "UU7zvyJj/3XOam9C/z9j/9XBZJ28KVnrYWaadXTbI2njhLZ3nYxP2/8JAAD//w==", +} + +// decodeSpec returns the embedded OpenAPI spec as raw JSON bytes, +// after base64-decoding and flate-decompressing the embedded blob. +func decodeSpec() ([]byte, error) { + encoded := strings.Join(swaggerSpec, "") + compressed, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + return nil, fmt.Errorf("error base64 decoding spec: %w", err) + } + zr := flate.NewReader(bytes.NewReader(compressed)) + var buf bytes.Buffer + if _, err := buf.ReadFrom(zr); err != nil { + return nil, fmt.Errorf("read flate: %w", err) + } + if err := zr.Close(); err != nil { + return nil, fmt.Errorf("close flate reader: %w", err) + } + + return buf.Bytes(), nil +} + +var rawSpec = decodeSpecCached() + +// a naive cache of the decoded OpenAPI spec +func decodeSpecCached() func() ([]byte, error) { + data, err := decodeSpec() + return func() ([]byte, error) { + return data, err + } +} + +// Constructs a synthetic filesystem for resolving external references when loading openapi specifications. +func PathToRawSpec(pathToFile string) map[string]func() ([]byte, error) { + res := make(map[string]func() ([]byte, error)) + if len(pathToFile) > 0 { + res[pathToFile] = rawSpec + } + + return res +} + +// GetSpec returns the OpenAPI specification corresponding to the generated +// code in this file. External references in the spec are resolved through +// PathToRawSpec; externally-referenced files must be embedded in their +// corresponding Go packages (via the import-mapping feature). URL-based +// external refs are not supported. +func GetSpec() (swagger *openapi3.T, err error) { + resolvePath := PathToRawSpec("") + + loader := openapi3.NewLoader() + loader.IsExternalRefsAllowed = true + loader.ReadFromURIFunc = func(loader *openapi3.Loader, url *url.URL) ([]byte, error) { + pathToFile := url.String() + pathToFile = path.Clean(pathToFile) + getSpec, ok := resolvePath[pathToFile] + if !ok { + err1 := fmt.Errorf("path not found: %s", pathToFile) + return nil, err1 + } + return getSpec() + } + var specData []byte + specData, err = rawSpec() + if err != nil { + return + } + swagger, err = loader.LoadFromData(specData) + if err != nil { + return + } + return +} + +// GetSpecJSON returns the raw JSON bytes of the embedded OpenAPI +// specification: decompressed but not unmarshaled. External references +// are not resolved here; the bytes are the spec exactly as embedded by +// codegen. The result is cached at package init time, so repeated calls +// are cheap. +func GetSpecJSON() ([]byte, error) { + return rawSpec() +} + +// GetSwagger returns the OpenAPI specification corresponding to the +// generated code in this file. +// +// Deprecated: GetSwagger predates kin-openapi renaming openapi3.Swagger +// to openapi3.T. Use [GetSpec] instead. This wrapper is retained for +// backwards compatibility. +func GetSwagger() (*openapi3.T, error) { + return GetSpec() +} diff --git a/api/gitops/v1alpha1/types.gen.cfg b/api/gitops/v1alpha1/types.gen.cfg new file mode 100644 index 0000000..e34582e --- /dev/null +++ b/api/gitops/v1alpha1/types.gen.cfg @@ -0,0 +1,6 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/oapi-codegen/oapi-codegen/HEAD/configuration-schema.json +package: v1alpha1 +generate: + models: true +output-options: + skip-prune: true diff --git a/api/gitops/v1alpha1/types.gen.go b/api/gitops/v1alpha1/types.gen.go new file mode 100644 index 0000000..2581745 --- /dev/null +++ b/api/gitops/v1alpha1/types.gen.go @@ -0,0 +1,242 @@ +// Package v1alpha1 provides primitives to interact with the openapi HTTP API. +// +// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.7.0 DO NOT EDIT. +package v1alpha1 + +import ( + "time" +) + +// Defines values for ErrorType. +const ( + ALREADYEXISTS ErrorType = "ALREADY_EXISTS" + FAILEDPRECONDITION ErrorType = "FAILED_PRECONDITION" + INTERNAL ErrorType = "INTERNAL" + INVALIDARGUMENT ErrorType = "INVALID_ARGUMENT" + NOTFOUND ErrorType = "NOT_FOUND" + PERMISSIONDENIED ErrorType = "PERMISSION_DENIED" + UNAUTHENTICATED ErrorType = "UNAUTHENTICATED" + UNAVAILABLE ErrorType = "UNAVAILABLE" +) + +// Valid indicates whether the value is a known member of the ErrorType enum. +func (e ErrorType) Valid() bool { + switch e { + case ALREADYEXISTS: + return true + case FAILEDPRECONDITION: + return true + case INTERNAL: + return true + case INVALIDARGUMENT: + return true + case NOTFOUND: + return true + case PERMISSIONDENIED: + return true + case UNAUTHENTICATED: + return true + case UNAVAILABLE: + return true + default: + return false + } +} + +// Defines values for GitRepositoryStatusSyncState. +const ( + ERROR GitRepositoryStatusSyncState = "ERROR" + INPROGRESS GitRepositoryStatusSyncState = "IN_PROGRESS" + PENDING GitRepositoryStatusSyncState = "PENDING" + SYNCED GitRepositoryStatusSyncState = "SYNCED" +) + +// Valid indicates whether the value is a known member of the GitRepositoryStatusSyncState enum. +func (e GitRepositoryStatusSyncState) Valid() bool { + switch e { + case ERROR: + return true + case INPROGRESS: + return true + case PENDING: + return true + case SYNCED: + return true + default: + return false + } +} + +// Error Error response following RFC 7807 Problem Details for HTTP APIs +// and AEP-193 Error Responses specification. +type Error struct { + // Detail Human-readable explanation specific to this occurrence of the error. + Detail *string `json:"detail,omitempty"` + + // Instance Unique identifier for this specific error occurrence. + Instance *string `json:"instance,omitempty"` + + // Status HTTP status code (matches the response status) + Status int32 `json:"status"` + + // Title Short, human-readable summary of the error type. + Title string `json:"title"` + + // Type Machine-readable error code. + Type ErrorType `json:"type"` +} + +// ErrorType Machine-readable error code. +type ErrorType string + +// GitRepository defines model for GitRepository. +type GitRepository struct { + // ApiVersion Version of the GitRepository schema (e.g., v1alpha1). + // Immutable after creation. + ApiVersion string `json:"api_version"` + + // CreateTime Timestamp when the resource was created (RFC 3339) + CreateTime *time.Time `json:"create_time,omitempty"` + + // DisplayName User-friendly display name for the git repository configuration. + DisplayName string `json:"display_name"` + + // Path Resource path in the format: git-repositories/{gitRepositoryId} + Path *string `json:"path,omitempty"` + + // Spec Specification for a git repository connection. + Spec GitRepositorySpec `json:"spec"` + + // Status Sync status for a git repository. + Status *GitRepositoryStatus `json:"status,omitempty"` + + // Uid Unique identifier for the git repository. This field is output-only and + // immutable after creation. The ID can be optionally specified via + // query parameter on creation; if not provided, the server generates a UUID. + // + // Follows AEP-122 resource ID conventions. + Uid *string `json:"uid,omitempty"` + + // UpdateTime Timestamp when the resource was last modified (RFC 3339) + UpdateTime *time.Time `json:"update_time,omitempty"` +} + +// GitRepositoryList defines model for GitRepositoryList. +type GitRepositoryList struct { + // NextPageToken Token for retrieving the next page of results. + // Empty string indicates this is the last page. + NextPageToken string `json:"next_page_token"` + + // Results Array of git repository resources. + // May be empty if no results match the query. + Results []GitRepository `json:"results"` +} + +// GitRepositorySpec Specification for a git repository connection. +type GitRepositorySpec struct { + // IntervalSeconds Polling interval in seconds (minimum 10s). + IntervalSeconds *int32 `json:"interval_seconds,omitempty"` + + // Path Directory within the repo. Only YAML files directly at this + // path root are read (not subdirectories). + Path *string `json:"path,omitempty"` + + // Reconciliation Reconciliation behavior configuration. + Reconciliation *struct { + RetryPolicy *struct { + // BackoffSeconds Backoff between retries in seconds. + BackoffSeconds *int32 `json:"backoff_seconds,omitempty"` + + // MaxRetries Maximum number of retries on submit failure. + MaxRetries *int32 `json:"max_retries,omitempty"` + } `json:"retry_policy,omitempty"` + } `json:"reconciliation,omitempty"` + + // Ref Git reference to watch. + Ref *struct { + // Branch Branch to watch. + Branch *string `json:"branch,omitempty"` + } `json:"ref,omitempty"` + + // Url Git repository URL. + Url string `json:"url"` +} + +// GitRepositoryStatus Sync status for a git repository. +type GitRepositoryStatus struct { + // LastSyncTime Timestamp of the last sync attempt (RFC 3339). + LastSyncTime *time.Time `json:"last_sync_time,omitempty"` + + // LastSyncedCommit The Git commit SHA that was last successfully synced. + LastSyncedCommit *string `json:"last_synced_commit,omitempty"` + + // Message Human-readable message about the current status. + // Typically contains error details when sync_state is ERROR. + Message *string `json:"message,omitempty"` + + // SyncState Current sync state. + // - PENDING: Initial state, no sync has occurred yet. + // - SYNCED: DCM state matches Git at lastSyncedCommit. + // - IN_PROGRESS: Lifecycle changes from Git are being applied. + // - ERROR: Sync failed (auth, network, parse errors). + SyncState *GitRepositoryStatusSyncState `json:"sync_state,omitempty"` +} + +// GitRepositoryStatusSyncState Current sync state. +// - PENDING: Initial state, no sync has occurred yet. +// - SYNCED: DCM state matches Git at lastSyncedCommit. +// - IN_PROGRESS: Lifecycle changes from Git are being applied. +// - ERROR: Sync failed (auth, network, parse errors). +type GitRepositoryStatusSyncState string + +// GitRepositoryIdPath defines model for GitRepositoryIdPath. +type GitRepositoryIdPath = string + +// AlreadyExists Error response following RFC 7807 Problem Details for HTTP APIs +// and AEP-193 Error Responses specification. +type AlreadyExists = Error + +// BadRequest Error response following RFC 7807 Problem Details for HTTP APIs +// and AEP-193 Error Responses specification. +type BadRequest = Error + +// Forbidden Error response following RFC 7807 Problem Details for HTTP APIs +// and AEP-193 Error Responses specification. +type Forbidden = Error + +// InternalServerError Error response following RFC 7807 Problem Details for HTTP APIs +// and AEP-193 Error Responses specification. +type InternalServerError = Error + +// NotFound Error response following RFC 7807 Problem Details for HTTP APIs +// and AEP-193 Error Responses specification. +type NotFound = Error + +// Unauthorized Error response following RFC 7807 Problem Details for HTTP APIs +// and AEP-193 Error Responses specification. +type Unauthorized = Error + +// ListGitRepositoriesParams defines parameters for ListGitRepositories. +type ListGitRepositoriesParams struct { + // PageToken Token for retrieving the next page of results. + // Obtained from the next_page_token field of a previous response. + PageToken *string `form:"page_token,omitempty" json:"page_token,omitempty"` + + // MaxPageSize Maximum number of items to return per page. + // If not specified, defaults to 100. + MaxPageSize *int32 `form:"max_page_size,omitempty" json:"max_page_size,omitempty"` +} + +// CreateGitRepositoryParams defines parameters for CreateGitRepository. +type CreateGitRepositoryParams struct { + // Id Optional user-specified ID for the git repository. + // Must follow DNS-1123 label format (lowercase alphanumeric with hyphens). + // If omitted, the server generates an ID. + Id *string `form:"id,omitempty" json:"id,omitempty"` +} + +// CreateGitRepositoryJSONRequestBody defines body for CreateGitRepository for application/json ContentType. +type CreateGitRepositoryJSONRequestBody = GitRepository + +// UpdateGitRepositoryJSONRequestBody defines body for UpdateGitRepository for application/json ContentType. +type UpdateGitRepositoryJSONRequestBody = GitRepository diff --git a/cmd/dcm-gitops/main.go b/cmd/dcm-gitops/main.go new file mode 100644 index 0000000..adc8d62 --- /dev/null +++ b/cmd/dcm-gitops/main.go @@ -0,0 +1,11 @@ +package main + +import ( + "os" + + gitopsapp "github.com/dcm-project/control-plane/internal/gitops/app" +) + +func main() { + os.Exit(gitopsapp.Run()) +} diff --git a/go.mod b/go.mod index d93dacf..654489c 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/coreos/go-oidc/v3 v3.20.0 github.com/getkin/kin-openapi v0.139.0 github.com/go-chi/chi/v5 v5.3.0 + github.com/go-git/go-git/v5 v5.19.2 github.com/google/uuid v1.6.0 github.com/jackc/pgx/v5 v5.8.0 github.com/kelseyhightower/envconfig v1.4.0 @@ -21,20 +22,29 @@ require ( github.com/open-policy-agent/opa v1.17.0 github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 github.com/stretchr/testify v1.11.1 + gopkg.in/yaml.v3 v3.0.1 gorm.io/driver/postgres v1.6.0 gorm.io/driver/sqlite v1.6.0 gorm.io/gorm v1.31.1 ) require ( + dario.cat/mergo v1.0.0 // indirect github.com/Masterminds/semver/v3 v3.4.0 // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/ProtonMail/go-crypto v1.1.6 // indirect github.com/agnivade/levenshtein v1.2.1 // indirect github.com/antithesishq/antithesis-sdk-go v0.6.0-default-no-op // indirect github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/cloudflare/circl v1.6.3 // indirect + github.com/cyphar/filepath-securejoin v0.6.1 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 // indirect github.com/dlclark/regexp2 v1.11.5 // indirect + github.com/emirpasic/gods v1.18.1 // indirect + github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect + github.com/go-git/go-billy/v5 v5.9.0 // indirect github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-openapi/jsonpointer v0.22.4 // indirect @@ -42,6 +52,7 @@ require ( github.com/go-task/slim-sprig/v3 v3.0.0 // indirect github.com/gobwas/glob v0.2.3 // indirect github.com/goccy/go-json v0.10.6 // indirect + github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/go-tpm v0.9.8 // indirect github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 // indirect @@ -49,11 +60,14 @@ require ( github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect + github.com/kevinburke/ssh_config v1.2.0 // indirect github.com/klauspost/compress v1.18.5 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/lestrrat-go/blackmagic v1.0.4 // indirect github.com/lestrrat-go/dsig v1.2.1 // indirect github.com/lestrrat-go/dsig-secp256k1 v1.0.0 // indirect @@ -73,28 +87,32 @@ require ( github.com/oasdiff/yaml v0.1.0 // indirect github.com/oasdiff/yaml3 v0.0.13 // indirect github.com/perimeterx/marshmallow v1.1.5 // indirect + github.com/pjbgf/sha1cd v0.6.0 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 // indirect github.com/segmentio/asm v1.2.1 // indirect + github.com/sergi/go-diff v1.4.0 // indirect github.com/sirupsen/logrus v1.9.4 // indirect + github.com/skeema/knownhosts v1.3.1 // indirect github.com/tchap/go-patricia/v2 v2.3.3 // indirect github.com/valyala/fastjson v1.6.10 // indirect github.com/vektah/gqlparser/v2 v2.5.33 // indirect github.com/woodsbury/decimal128 v1.4.0 // indirect + github.com/xanzy/ssh-agent v0.3.3 // indirect github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect github.com/yashtewari/glob-intersection v0.2.0 // indirect go.yaml.in/yaml/v2 v2.4.4 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/crypto v0.51.0 // indirect - golang.org/x/mod v0.35.0 // indirect - golang.org/x/net v0.53.0 // indirect + golang.org/x/crypto v0.53.0 // indirect + golang.org/x/mod v0.37.0 // indirect + golang.org/x/net v0.56.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect - golang.org/x/sync v0.20.0 // indirect - golang.org/x/sys v0.44.0 // indirect - golang.org/x/text v0.37.0 // indirect + golang.org/x/sync v0.21.0 // indirect + golang.org/x/sys v0.46.0 // indirect + golang.org/x/text v0.39.0 // indirect golang.org/x/time v0.15.0 // indirect - golang.org/x/tools v0.44.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect + golang.org/x/tools v0.47.0 // indirect + gopkg.in/warnings.v0 v0.1.2 // indirect sigs.k8s.io/yaml v1.6.0 // indirect ) diff --git a/go.sum b/go.sum index acc1789..2161049 100644 --- a/go.sum +++ b/go.sum @@ -1,16 +1,27 @@ +dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= +dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw= +github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM= github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU= github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ= github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= github.com/antithesishq/antithesis-sdk-go v0.6.0-default-no-op h1:kpBdlEPbRvff0mDD1gk7o9BhI16b9p5yYAXRlidpqJE= github.com/antithesishq/antithesis-sdk-go v0.6.0-default-no-op/go.mod h1:IUpT2DPAKh6i/YhSbt6Gl3v2yvUZjmKncl7U91fup7E= github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q= github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= @@ -24,8 +35,12 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 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/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= +github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= github.com/coreos/go-oidc/v3 v3.20.0 h1:EtE0WIBHk03N+DqGkY4+UONzzZHk7amKt6IyNd7OsZE= github.com/coreos/go-oidc/v3 v3.20.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4= +github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE= +github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= @@ -42,6 +57,10 @@ github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZ github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o= +github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= +github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= +github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw= github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g= github.com/foxcpp/go-mockdns v1.2.0 h1:omK3OrHRD1IWJz1FuFBCFquhXslXoF17OvBS6JPzZF0= @@ -54,8 +73,18 @@ github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZ github.com/gkampitakis/go-diff v1.3.2/go.mod h1:LLgOrpqleQe26cte8s36HTWcTmMEur6OPYerdAAS9tk= github.com/gkampitakis/go-snaps v0.5.15 h1:amyJrvM1D33cPHwVrjo9jQxX8g/7E2wYdZ+01KS3zGE= github.com/gkampitakis/go-snaps v0.5.15/go.mod h1:HNpx/9GoKisdhw9AFOBT1N7DBs9DiHo/hGheFGBZ+mc= +github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= +github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM= github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= +github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= +github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= +github.com/go-git/go-billy/v5 v5.9.0 h1:jItGXszUDRtR/AlferWPTMN4j38BQ88XnXKbilmmBPA= +github.com/go-git/go-billy/v5 v5.9.0/go.mod h1:jCnQMLj9eUgGU7+ludSTYoZL/GGmii14RxKFj7ROgHw= +github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4= +github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= +github.com/go-git/go-git/v5 v5.19.2 h1:wkfn7vOlUBu8ivAWKBWisTiwJK4jYHzTF8Ndv1LyGqY= +github.com/go-git/go-git/v5 v5.19.2/go.mod h1:QqCBE1EFN5ddFmrliLQ3/ntRCUjZU3EJuwuB/jWEHjk= github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= @@ -78,6 +107,8 @@ github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= github.com/google/flatbuffers v25.2.10+incompatible h1:F3vclr7C3HpB1k9mxCGRMXq6FdUalZ6H/pNX4FP1v0Q= github.com/google/flatbuffers v25.2.10+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= @@ -99,6 +130,8 @@ github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo= github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= @@ -112,10 +145,17 @@ github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHm github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8= github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg= +github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= +github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/lestrrat-go/blackmagic v1.0.4 h1:IwQibdnf8l2KoO+qC3uT4OaTWsW7tuRQXy9TRN9QanA= @@ -181,6 +221,10 @@ github.com/open-policy-agent/opa v1.17.0 h1:TMm6bCyb3CEL4wjXsXn1d/kBSBbjF+5sEIyz github.com/open-policy-agent/opa v1.17.0/go.mod h1:lcuZYSlqQpXFzsA6EJCELmfR5+nNOpZYX+eo7xaIIlk= github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s= github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw= +github.com/pjbgf/sha1cd v0.6.0 h1:3WJ8Wz8gvDz29quX1OcEmkAlUg9diU4GxJHqs0/XiwU= +github.com/pjbgf/sha1cd v0.6.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -202,11 +246,16 @@ github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= +github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= +github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8= +github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY= github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= @@ -229,6 +278,8 @@ github.com/vektah/gqlparser/v2 v2.5.33 h1:lRp8aIeNUNbimf/axZd7ETg24q06hBtPaas+Tc github.com/vektah/gqlparser/v2 v2.5.33/go.mod h1:c1I28gSOVNzlfc4WuDlqU7voQnsqI6OG2amkBAFmgts= github.com/woodsbury/decimal128 v1.4.0 h1:xJATj7lLu4f2oObouMt2tgGiElE5gO6mSWUjQsBgUlc= github.com/woodsbury/decimal128 v1.4.0/go.mod h1:BP46FUrVjVhdTbKT+XuQh2xfQaGki9LMIRJSFuh6THU= +github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= +github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo= github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= @@ -247,30 +298,48 @@ go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= 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.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= -golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= -golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= -golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= -golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= -golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= +golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= -golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= -golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +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/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= +golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus= +golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= -golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= -golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/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/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= +gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 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/internal/app/db.go b/internal/app/db.go index bbe110e..7426177 100644 --- a/internal/app/db.go +++ b/internal/app/db.go @@ -9,6 +9,7 @@ import ( agentmodel "github.com/dcm-project/control-plane/internal/agent/store/model" authmodel "github.com/dcm-project/control-plane/internal/auth/store/model" catalogmodel "github.com/dcm-project/control-plane/internal/catalog/store/model" + gitopsmodel "github.com/dcm-project/control-plane/internal/gitops/store/model" placementmodel "github.com/dcm-project/control-plane/internal/placement/store/model" policymodel "github.com/dcm-project/control-plane/internal/policy/store/model" spmodel "github.com/dcm-project/control-plane/internal/sp/store/model" @@ -76,6 +77,7 @@ func openDB(cfg *Config) (*gorm.DB, error) { &catalogmodel.ServiceType{}, &catalogmodel.CatalogItem{}, &catalogmodel.CatalogItemInstance{}, + &gitopsmodel.GitRepository{}, &placementmodel.Resource{}, &policymodel.Policy{}, &spmodel.ServiceTypeInstance{}, diff --git a/internal/app/openapi.go b/internal/app/openapi.go index 7fb7870..f036d9a 100644 --- a/internal/app/openapi.go +++ b/internal/app/openapi.go @@ -12,6 +12,7 @@ import ( agentapi "github.com/dcm-project/control-plane/api/agent/v1alpha1" catalogapi "github.com/dcm-project/control-plane/api/catalog/v1alpha1" + gitopsapi "github.com/dcm-project/control-plane/api/gitops/v1alpha1" policyapi "github.com/dcm-project/control-plane/api/policy/v1alpha1" sprmapi "github.com/dcm-project/control-plane/api/sp/v1alpha1/resource_manager" "github.com/getkin/kin-openapi/openapi3" @@ -24,6 +25,7 @@ const apiV1Alpha1Prefix = "/api/v1alpha1" type openAPIValidators struct { agent func(http.Handler) http.Handler catalog func(http.Handler) http.Handler + gitops func(http.Handler) http.Handler policy func(http.Handler) http.Handler rm func(http.Handler) http.Handler } @@ -39,6 +41,11 @@ func newOpenAPIValidators() (*openAPIValidators, error) { return nil, fmt.Errorf("load catalog OpenAPI spec: %w", err) } + gitopsSpec, err := gitopsapi.GetSpec() + if err != nil { + return nil, fmt.Errorf("load gitops OpenAPI spec: %w", err) + } + policySpec, err := policyapi.GetSpec() if err != nil { return nil, fmt.Errorf("load policy OpenAPI spec: %w", err) @@ -52,6 +59,7 @@ func newOpenAPIValidators() (*openAPIValidators, error) { return &openAPIValidators{ agent: oapiRequestValidator(agentSpec), catalog: oapiRequestValidator(catalogSpec), + gitops: oapiRequestValidator(gitopsSpec), policy: oapiRequestValidator(policySpec), rm: oapiRequestValidator(rmSpec), }, nil @@ -119,6 +127,8 @@ func (v *openAPIValidators) middleware() func(http.Handler) http.Handler { v.rm(next).ServeHTTP(w, r) case strings.HasPrefix(path, apiV1Alpha1Prefix+"/policies"): v.policy(next).ServeHTTP(w, r) + case strings.HasPrefix(path, apiV1Alpha1Prefix+"/git-repositories"): + v.gitops(next).ServeHTTP(w, r) default: v.catalog(next).ServeHTTP(w, r) } diff --git a/internal/app/run.go b/internal/app/run.go index ec3a4df..9329c03 100644 --- a/internal/app/run.go +++ b/internal/app/run.go @@ -26,6 +26,10 @@ import ( catalogplacement "github.com/dcm-project/control-plane/internal/catalog/placement" catalogservice "github.com/dcm-project/control-plane/internal/catalog/service" catalogstore "github.com/dcm-project/control-plane/internal/catalog/store" + gitopsserver "github.com/dcm-project/control-plane/internal/gitops/api/server" + gitopshandlers "github.com/dcm-project/control-plane/internal/gitops/handlers/v1alpha1" + gitopsservice "github.com/dcm-project/control-plane/internal/gitops/service" + gitopsstore "github.com/dcm-project/control-plane/internal/gitops/store" placementagent "github.com/dcm-project/control-plane/internal/placement/agent" placementlogging "github.com/dcm-project/control-plane/internal/placement/logging" placementpolicy "github.com/dcm-project/control-plane/internal/placement/policy" @@ -88,6 +92,7 @@ func Run() int { authSvc := authservice.NewService(authDataStore, cfg.Auth.AdminSubject, logger) catalogDataStore := catalogstore.NewStore(db, logger) + gitopsDataStore := gitopsstore.NewStore(db) policyDataStore := policystore.NewStore(db) placementDataStore := placementstore.NewStore(db) spDataStore := spstore.NewStore(db) @@ -250,9 +255,12 @@ func Run() int { authMiddleware = auth.Middleware(mwCfg) } + gitopsSvc := gitopsservice.NewGitRepositoryService(gitopsDataStore) + router, err := newRouter(authMiddleware, RouteHandlers{ Agent: agenthandlers.NewHandler(agentSvc), Catalog: cataloghandlers.NewHandler(catalogSvc, logger), + Gitops: gitopshandlers.NewHandler(gitopsSvc), Policy: policyhandlers.NewPolicyHandler(policyService), SPRM: sprmhandler.NewHandler(spInstanceService), }, checkers...) @@ -296,6 +304,7 @@ func Run() int { type RouteHandlers struct { Agent agentserver.StrictServerInterface Catalog catalogserver.StrictServerInterface + Gitops gitopsserver.StrictServerInterface Policy policyserver.StrictServerInterface SPRM sprmserver.StrictServerInterface } @@ -331,6 +340,11 @@ func newRouter(authMW func(http.Handler) http.Handler, h RouteHandlers, checkers router, baseURL, ) + gitopsserver.HandlerFromMuxWithBaseURL( + gitopsserver.NewStrictHandler(h.Gitops, nil), + router, + baseURL, + ) apiRouter := chi.NewRouter() sprmserver.HandlerFromMux( diff --git a/internal/gitops/api/server/server.gen.cfg b/internal/gitops/api/server/server.gen.cfg new file mode 100644 index 0000000..665ce30 --- /dev/null +++ b/internal/gitops/api/server/server.gen.cfg @@ -0,0 +1,10 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/oapi-codegen/oapi-codegen/HEAD/configuration-schema.json +package: server +generate: + chi-server: true + strict-server: true +additional-imports: + - alias: . + package: github.com/dcm-project/control-plane/api/gitops/v1alpha1 +output-options: + skip-prune: true diff --git a/internal/gitops/api/server/server.gen.go b/internal/gitops/api/server/server.gen.go new file mode 100644 index 0000000..d3c3876 --- /dev/null +++ b/internal/gitops/api/server/server.gen.go @@ -0,0 +1,997 @@ +// Package server provides primitives to interact with the openapi HTTP API. +// +// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.7.0 DO NOT EDIT. +package server + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + + . "github.com/dcm-project/control-plane/api/gitops/v1alpha1" + "github.com/go-chi/chi/v5" + "github.com/oapi-codegen/runtime" +) + +// ServerInterface represents all server handlers. +type ServerInterface interface { + // List git repositories + // (GET /git-repositories) + ListGitRepositories(w http.ResponseWriter, r *http.Request, params ListGitRepositoriesParams) + // Create a git repository + // (POST /git-repositories) + CreateGitRepository(w http.ResponseWriter, r *http.Request, params CreateGitRepositoryParams) + // Delete a git repository + // (DELETE /git-repositories/{gitRepositoryId}) + DeleteGitRepository(w http.ResponseWriter, r *http.Request, gitRepositoryId GitRepositoryIdPath) + // Get a git repository + // (GET /git-repositories/{gitRepositoryId}) + GetGitRepository(w http.ResponseWriter, r *http.Request, gitRepositoryId GitRepositoryIdPath) + // Update a git repository + // (PUT /git-repositories/{gitRepositoryId}) + UpdateGitRepository(w http.ResponseWriter, r *http.Request, gitRepositoryId GitRepositoryIdPath) +} + +// Unimplemented server implementation that returns http.StatusNotImplemented for each endpoint. + +type Unimplemented struct{} + +// List git repositories +// (GET /git-repositories) +func (_ Unimplemented) ListGitRepositories(w http.ResponseWriter, r *http.Request, params ListGitRepositoriesParams) { + w.WriteHeader(http.StatusNotImplemented) +} + +// Create a git repository +// (POST /git-repositories) +func (_ Unimplemented) CreateGitRepository(w http.ResponseWriter, r *http.Request, params CreateGitRepositoryParams) { + w.WriteHeader(http.StatusNotImplemented) +} + +// Delete a git repository +// (DELETE /git-repositories/{gitRepositoryId}) +func (_ Unimplemented) DeleteGitRepository(w http.ResponseWriter, r *http.Request, gitRepositoryId GitRepositoryIdPath) { + w.WriteHeader(http.StatusNotImplemented) +} + +// Get a git repository +// (GET /git-repositories/{gitRepositoryId}) +func (_ Unimplemented) GetGitRepository(w http.ResponseWriter, r *http.Request, gitRepositoryId GitRepositoryIdPath) { + w.WriteHeader(http.StatusNotImplemented) +} + +// Update a git repository +// (PUT /git-repositories/{gitRepositoryId}) +func (_ Unimplemented) UpdateGitRepository(w http.ResponseWriter, r *http.Request, gitRepositoryId GitRepositoryIdPath) { + w.WriteHeader(http.StatusNotImplemented) +} + +// ServerInterfaceWrapper converts contexts to parameters. +type ServerInterfaceWrapper struct { + Handler ServerInterface + HandlerMiddlewares []MiddlewareFunc + ErrorHandlerFunc func(w http.ResponseWriter, r *http.Request, err error) +} + +type MiddlewareFunc func(http.Handler) http.Handler + +// ListGitRepositories operation middleware +func (siw *ServerInterfaceWrapper) ListGitRepositories(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // Parameter object where we will unmarshal all parameters from the context + var params ListGitRepositoriesParams + + // ------------- Optional query parameter "page_token" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "page_token", r.URL.Query(), ¶ms.PageToken, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "page_token"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "page_token", Err: err}) + } + return + } + + // ------------- Optional query parameter "max_page_size" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "max_page_size", r.URL.Query(), ¶ms.MaxPageSize, runtime.BindQueryParameterOptions{Type: "integer", Format: "int32"}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "max_page_size"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "max_page_size", Err: err}) + } + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.ListGitRepositories(w, r, params) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// CreateGitRepository operation middleware +func (siw *ServerInterfaceWrapper) CreateGitRepository(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // Parameter object where we will unmarshal all parameters from the context + var params CreateGitRepositoryParams + + // ------------- Optional query parameter "id" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "id", r.URL.Query(), ¶ms.Id, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "id"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + } + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.CreateGitRepository(w, r, params) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// DeleteGitRepository operation middleware +func (siw *ServerInterfaceWrapper) DeleteGitRepository(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "gitRepositoryId" ------------- + var gitRepositoryId GitRepositoryIdPath + + err = runtime.BindStyledParameterWithOptions("simple", "gitRepositoryId", chi.URLParam(r, "gitRepositoryId"), &gitRepositoryId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: ""}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "gitRepositoryId", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.DeleteGitRepository(w, r, gitRepositoryId) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// GetGitRepository operation middleware +func (siw *ServerInterfaceWrapper) GetGitRepository(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "gitRepositoryId" ------------- + var gitRepositoryId GitRepositoryIdPath + + err = runtime.BindStyledParameterWithOptions("simple", "gitRepositoryId", chi.URLParam(r, "gitRepositoryId"), &gitRepositoryId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: ""}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "gitRepositoryId", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.GetGitRepository(w, r, gitRepositoryId) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// UpdateGitRepository operation middleware +func (siw *ServerInterfaceWrapper) UpdateGitRepository(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "gitRepositoryId" ------------- + var gitRepositoryId GitRepositoryIdPath + + err = runtime.BindStyledParameterWithOptions("simple", "gitRepositoryId", chi.URLParam(r, "gitRepositoryId"), &gitRepositoryId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: ""}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "gitRepositoryId", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.UpdateGitRepository(w, r, gitRepositoryId) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +type UnescapedCookieParamError struct { + ParamName string + Err error +} + +func (e *UnescapedCookieParamError) Error() string { + return fmt.Sprintf("error unescaping cookie parameter '%s'", e.ParamName) +} + +func (e *UnescapedCookieParamError) Unwrap() error { + return e.Err +} + +type UnmarshalingParamError struct { + ParamName string + Err error +} + +func (e *UnmarshalingParamError) Error() string { + return fmt.Sprintf("Error unmarshaling parameter %s as JSON: %s", e.ParamName, e.Err.Error()) +} + +func (e *UnmarshalingParamError) Unwrap() error { + return e.Err +} + +type RequiredParamError struct { + ParamName string +} + +func (e *RequiredParamError) Error() string { + return fmt.Sprintf("Query argument %s is required, but not found", e.ParamName) +} + +type RequiredHeaderError struct { + ParamName string + Err error +} + +func (e *RequiredHeaderError) Error() string { + return fmt.Sprintf("Header parameter %s is required, but not found", e.ParamName) +} + +func (e *RequiredHeaderError) Unwrap() error { + return e.Err +} + +type InvalidParamFormatError struct { + ParamName string + Err error +} + +func (e *InvalidParamFormatError) Error() string { + return fmt.Sprintf("Invalid format for parameter %s: %s", e.ParamName, e.Err.Error()) +} + +func (e *InvalidParamFormatError) Unwrap() error { + return e.Err +} + +type TooManyValuesForParamError struct { + ParamName string + Count int +} + +func (e *TooManyValuesForParamError) Error() string { + return fmt.Sprintf("Expected one value for %s, got %d", e.ParamName, e.Count) +} + +// Handler creates http.Handler with routing matching OpenAPI spec. +func Handler(si ServerInterface) http.Handler { + return HandlerWithOptions(si, ChiServerOptions{}) +} + +type ChiServerOptions struct { + BaseURL string + BaseRouter chi.Router + Middlewares []MiddlewareFunc + ErrorHandlerFunc func(w http.ResponseWriter, r *http.Request, err error) +} + +// HandlerFromMux creates http.Handler with routing matching OpenAPI spec based on the provided mux. +func HandlerFromMux(si ServerInterface, r chi.Router) http.Handler { + return HandlerWithOptions(si, ChiServerOptions{ + BaseRouter: r, + }) +} + +func HandlerFromMuxWithBaseURL(si ServerInterface, r chi.Router, baseURL string) http.Handler { + return HandlerWithOptions(si, ChiServerOptions{ + BaseURL: baseURL, + BaseRouter: r, + }) +} + +// HandlerWithOptions creates http.Handler with additional options +func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handler { + r := options.BaseRouter + + if r == nil { + r = chi.NewRouter() + } + if options.ErrorHandlerFunc == nil { + options.ErrorHandlerFunc = func(w http.ResponseWriter, r *http.Request, err error) { + http.Error(w, err.Error(), http.StatusBadRequest) + } + } + wrapper := ServerInterfaceWrapper{ + Handler: si, + HandlerMiddlewares: options.Middlewares, + ErrorHandlerFunc: options.ErrorHandlerFunc, + } + + r.Group(func(r chi.Router) { + r.Get(options.BaseURL+"/git-repositories", wrapper.ListGitRepositories) + }) + r.Group(func(r chi.Router) { + r.Post(options.BaseURL+"/git-repositories", wrapper.CreateGitRepository) + }) + r.Group(func(r chi.Router) { + r.Delete(options.BaseURL+"/git-repositories/{gitRepositoryId}", wrapper.DeleteGitRepository) + }) + r.Group(func(r chi.Router) { + r.Get(options.BaseURL+"/git-repositories/{gitRepositoryId}", wrapper.GetGitRepository) + }) + r.Group(func(r chi.Router) { + r.Put(options.BaseURL+"/git-repositories/{gitRepositoryId}", wrapper.UpdateGitRepository) + }) + + return r +} + +type AlreadyExistsJSONResponse Error + +type BadRequestJSONResponse Error + +type ForbiddenJSONResponse Error + +type InternalServerErrorJSONResponse Error + +type NotFoundJSONResponse Error + +type UnauthorizedJSONResponse Error + +type ListGitRepositoriesRequestObject struct { + Params ListGitRepositoriesParams +} + +type ListGitRepositoriesResponseObject interface { + VisitListGitRepositoriesResponse(w http.ResponseWriter) error +} + +type ListGitRepositories200JSONResponse GitRepositoryList + +func (response ListGitRepositories200JSONResponse) VisitListGitRepositoriesResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + _, err := buf.WriteTo(w) + return err +} + +type ListGitRepositories400JSONResponse struct{ BadRequestJSONResponse } + +func (response ListGitRepositories400JSONResponse) VisitListGitRepositoriesResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(400) + _, err := buf.WriteTo(w) + return err +} + +type ListGitRepositories401JSONResponse struct{ UnauthorizedJSONResponse } + +func (response ListGitRepositories401JSONResponse) VisitListGitRepositoriesResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(401) + _, err := buf.WriteTo(w) + return err +} + +type ListGitRepositories403JSONResponse struct{ ForbiddenJSONResponse } + +func (response ListGitRepositories403JSONResponse) VisitListGitRepositoriesResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(403) + _, err := buf.WriteTo(w) + return err +} + +type ListGitRepositories500JSONResponse struct { + InternalServerErrorJSONResponse +} + +func (response ListGitRepositories500JSONResponse) VisitListGitRepositoriesResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(500) + _, err := buf.WriteTo(w) + return err +} + +type CreateGitRepositoryRequestObject struct { + Params CreateGitRepositoryParams + Body *CreateGitRepositoryJSONRequestBody +} + +type CreateGitRepositoryResponseObject interface { + VisitCreateGitRepositoryResponse(w http.ResponseWriter) error +} + +type CreateGitRepository201JSONResponse GitRepository + +func (response CreateGitRepository201JSONResponse) VisitCreateGitRepositoryResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(201) + _, err := buf.WriteTo(w) + return err +} + +type CreateGitRepository400JSONResponse Error + +func (response CreateGitRepository400JSONResponse) VisitCreateGitRepositoryResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(400) + _, err := buf.WriteTo(w) + return err +} + +type CreateGitRepository401JSONResponse struct{ UnauthorizedJSONResponse } + +func (response CreateGitRepository401JSONResponse) VisitCreateGitRepositoryResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(401) + _, err := buf.WriteTo(w) + return err +} + +type CreateGitRepository403JSONResponse struct{ ForbiddenJSONResponse } + +func (response CreateGitRepository403JSONResponse) VisitCreateGitRepositoryResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(403) + _, err := buf.WriteTo(w) + return err +} + +type CreateGitRepository409JSONResponse struct{ AlreadyExistsJSONResponse } + +func (response CreateGitRepository409JSONResponse) VisitCreateGitRepositoryResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(409) + _, err := buf.WriteTo(w) + return err +} + +type CreateGitRepository500JSONResponse struct { + InternalServerErrorJSONResponse +} + +func (response CreateGitRepository500JSONResponse) VisitCreateGitRepositoryResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(500) + _, err := buf.WriteTo(w) + return err +} + +type DeleteGitRepositoryRequestObject struct { + GitRepositoryId GitRepositoryIdPath `json:"gitRepositoryId"` +} + +type DeleteGitRepositoryResponseObject interface { + VisitDeleteGitRepositoryResponse(w http.ResponseWriter) error +} + +type DeleteGitRepository204Response struct { +} + +func (response DeleteGitRepository204Response) VisitDeleteGitRepositoryResponse(w http.ResponseWriter) error { + w.WriteHeader(204) + return nil +} + +type DeleteGitRepository401JSONResponse struct{ UnauthorizedJSONResponse } + +func (response DeleteGitRepository401JSONResponse) VisitDeleteGitRepositoryResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(401) + _, err := buf.WriteTo(w) + return err +} + +type DeleteGitRepository403JSONResponse struct{ ForbiddenJSONResponse } + +func (response DeleteGitRepository403JSONResponse) VisitDeleteGitRepositoryResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(403) + _, err := buf.WriteTo(w) + return err +} + +type DeleteGitRepository404JSONResponse struct{ NotFoundJSONResponse } + +func (response DeleteGitRepository404JSONResponse) VisitDeleteGitRepositoryResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(404) + _, err := buf.WriteTo(w) + return err +} + +type DeleteGitRepository500JSONResponse struct { + InternalServerErrorJSONResponse +} + +func (response DeleteGitRepository500JSONResponse) VisitDeleteGitRepositoryResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(500) + _, err := buf.WriteTo(w) + return err +} + +type GetGitRepositoryRequestObject struct { + GitRepositoryId GitRepositoryIdPath `json:"gitRepositoryId"` +} + +type GetGitRepositoryResponseObject interface { + VisitGetGitRepositoryResponse(w http.ResponseWriter) error +} + +type GetGitRepository200JSONResponse GitRepository + +func (response GetGitRepository200JSONResponse) VisitGetGitRepositoryResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + _, err := buf.WriteTo(w) + return err +} + +type GetGitRepository401JSONResponse struct{ UnauthorizedJSONResponse } + +func (response GetGitRepository401JSONResponse) VisitGetGitRepositoryResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(401) + _, err := buf.WriteTo(w) + return err +} + +type GetGitRepository403JSONResponse struct{ ForbiddenJSONResponse } + +func (response GetGitRepository403JSONResponse) VisitGetGitRepositoryResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(403) + _, err := buf.WriteTo(w) + return err +} + +type GetGitRepository404JSONResponse struct{ NotFoundJSONResponse } + +func (response GetGitRepository404JSONResponse) VisitGetGitRepositoryResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(404) + _, err := buf.WriteTo(w) + return err +} + +type GetGitRepository500JSONResponse struct { + InternalServerErrorJSONResponse +} + +func (response GetGitRepository500JSONResponse) VisitGetGitRepositoryResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(500) + _, err := buf.WriteTo(w) + return err +} + +type UpdateGitRepositoryRequestObject struct { + GitRepositoryId GitRepositoryIdPath `json:"gitRepositoryId"` + Body *UpdateGitRepositoryJSONRequestBody +} + +type UpdateGitRepositoryResponseObject interface { + VisitUpdateGitRepositoryResponse(w http.ResponseWriter) error +} + +type UpdateGitRepository200JSONResponse GitRepository + +func (response UpdateGitRepository200JSONResponse) VisitUpdateGitRepositoryResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + _, err := buf.WriteTo(w) + return err +} + +type UpdateGitRepository400JSONResponse Error + +func (response UpdateGitRepository400JSONResponse) VisitUpdateGitRepositoryResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(400) + _, err := buf.WriteTo(w) + return err +} + +type UpdateGitRepository401JSONResponse struct{ UnauthorizedJSONResponse } + +func (response UpdateGitRepository401JSONResponse) VisitUpdateGitRepositoryResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(401) + _, err := buf.WriteTo(w) + return err +} + +type UpdateGitRepository403JSONResponse struct{ ForbiddenJSONResponse } + +func (response UpdateGitRepository403JSONResponse) VisitUpdateGitRepositoryResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(403) + _, err := buf.WriteTo(w) + return err +} + +type UpdateGitRepository404JSONResponse struct{ NotFoundJSONResponse } + +func (response UpdateGitRepository404JSONResponse) VisitUpdateGitRepositoryResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(404) + _, err := buf.WriteTo(w) + return err +} + +type UpdateGitRepository500JSONResponse struct { + InternalServerErrorJSONResponse +} + +func (response UpdateGitRepository500JSONResponse) VisitUpdateGitRepositoryResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(500) + _, err := buf.WriteTo(w) + return err +} + +// StrictServerInterface represents all server handlers. +type StrictServerInterface interface { + // List git repositories + // (GET /git-repositories) + ListGitRepositories(ctx context.Context, request ListGitRepositoriesRequestObject) (ListGitRepositoriesResponseObject, error) + // Create a git repository + // (POST /git-repositories) + CreateGitRepository(ctx context.Context, request CreateGitRepositoryRequestObject) (CreateGitRepositoryResponseObject, error) + // Delete a git repository + // (DELETE /git-repositories/{gitRepositoryId}) + DeleteGitRepository(ctx context.Context, request DeleteGitRepositoryRequestObject) (DeleteGitRepositoryResponseObject, error) + // Get a git repository + // (GET /git-repositories/{gitRepositoryId}) + GetGitRepository(ctx context.Context, request GetGitRepositoryRequestObject) (GetGitRepositoryResponseObject, error) + // Update a git repository + // (PUT /git-repositories/{gitRepositoryId}) + UpdateGitRepository(ctx context.Context, request UpdateGitRepositoryRequestObject) (UpdateGitRepositoryResponseObject, error) +} + +type StrictHandlerFunc func(ctx context.Context, w http.ResponseWriter, r *http.Request, request any) (any, error) +type StrictMiddlewareFunc func(f StrictHandlerFunc, operationID string) StrictHandlerFunc + +type StrictHTTPServerOptions struct { + RequestErrorHandlerFunc func(w http.ResponseWriter, r *http.Request, err error) + ResponseErrorHandlerFunc func(w http.ResponseWriter, r *http.Request, err error) +} + +func NewStrictHandler(ssi StrictServerInterface, middlewares []StrictMiddlewareFunc) ServerInterface { + return &strictHandler{ssi: ssi, middlewares: middlewares, options: StrictHTTPServerOptions{ + RequestErrorHandlerFunc: func(w http.ResponseWriter, r *http.Request, err error) { + http.Error(w, err.Error(), http.StatusBadRequest) + }, + ResponseErrorHandlerFunc: func(w http.ResponseWriter, r *http.Request, err error) { + http.Error(w, err.Error(), http.StatusInternalServerError) + }, + }} +} + +func NewStrictHandlerWithOptions(ssi StrictServerInterface, middlewares []StrictMiddlewareFunc, options StrictHTTPServerOptions) ServerInterface { + return &strictHandler{ssi: ssi, middlewares: middlewares, options: options} +} + +type strictHandler struct { + ssi StrictServerInterface + middlewares []StrictMiddlewareFunc + options StrictHTTPServerOptions +} + +// ListGitRepositories operation middleware +func (sh *strictHandler) ListGitRepositories(w http.ResponseWriter, r *http.Request, params ListGitRepositoriesParams) { + var request ListGitRepositoriesRequestObject + + request.Params = params + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.ListGitRepositories(ctx, request.(ListGitRepositoriesRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "ListGitRepositories") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(ListGitRepositoriesResponseObject); ok { + if err := validResponse.VisitListGitRepositoriesResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + +// CreateGitRepository operation middleware +func (sh *strictHandler) CreateGitRepository(w http.ResponseWriter, r *http.Request, params CreateGitRepositoryParams) { + var request CreateGitRepositoryRequestObject + + request.Params = params + + var body CreateGitRepositoryJSONRequestBody + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + sh.options.RequestErrorHandlerFunc(w, r, fmt.Errorf("can't decode JSON body: %w", err)) + return + } + request.Body = &body + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.CreateGitRepository(ctx, request.(CreateGitRepositoryRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "CreateGitRepository") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(CreateGitRepositoryResponseObject); ok { + if err := validResponse.VisitCreateGitRepositoryResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + +// DeleteGitRepository operation middleware +func (sh *strictHandler) DeleteGitRepository(w http.ResponseWriter, r *http.Request, gitRepositoryId GitRepositoryIdPath) { + var request DeleteGitRepositoryRequestObject + + request.GitRepositoryId = gitRepositoryId + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.DeleteGitRepository(ctx, request.(DeleteGitRepositoryRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "DeleteGitRepository") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(DeleteGitRepositoryResponseObject); ok { + if err := validResponse.VisitDeleteGitRepositoryResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + +// GetGitRepository operation middleware +func (sh *strictHandler) GetGitRepository(w http.ResponseWriter, r *http.Request, gitRepositoryId GitRepositoryIdPath) { + var request GetGitRepositoryRequestObject + + request.GitRepositoryId = gitRepositoryId + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.GetGitRepository(ctx, request.(GetGitRepositoryRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "GetGitRepository") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(GetGitRepositoryResponseObject); ok { + if err := validResponse.VisitGetGitRepositoryResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + +// UpdateGitRepository operation middleware +func (sh *strictHandler) UpdateGitRepository(w http.ResponseWriter, r *http.Request, gitRepositoryId GitRepositoryIdPath) { + var request UpdateGitRepositoryRequestObject + + request.GitRepositoryId = gitRepositoryId + + var body UpdateGitRepositoryJSONRequestBody + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + sh.options.RequestErrorHandlerFunc(w, r, fmt.Errorf("can't decode JSON body: %w", err)) + return + } + request.Body = &body + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.UpdateGitRepository(ctx, request.(UpdateGitRepositoryRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "UpdateGitRepository") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(UpdateGitRepositoryResponseObject); ok { + if err := validResponse.VisitUpdateGitRepositoryResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + diff --git a/internal/gitops/app/run.go b/internal/gitops/app/run.go new file mode 100644 index 0000000..214d9b4 --- /dev/null +++ b/internal/gitops/app/run.go @@ -0,0 +1,181 @@ +// Package app implements the dcm-gitops controller process. +package app + +import ( + "context" + "fmt" + "log/slog" + "os" + "os/signal" + "strings" + "syscall" + "time" + + catalogconfig "github.com/dcm-project/control-plane/internal/catalog/config" + catalogplacement "github.com/dcm-project/control-plane/internal/catalog/placement" + catalogservice "github.com/dcm-project/control-plane/internal/catalog/service" + catalogstore "github.com/dcm-project/control-plane/internal/catalog/store" + "github.com/dcm-project/control-plane/internal/gitops/config" + "github.com/dcm-project/control-plane/internal/gitops/controller" + gitopsstore "github.com/dcm-project/control-plane/internal/gitops/store" + gitopsmodel "github.com/dcm-project/control-plane/internal/gitops/store/model" + placementpolicy "github.com/dcm-project/control-plane/internal/placement/policy" + placementservice "github.com/dcm-project/control-plane/internal/placement/service" + placementsprm "github.com/dcm-project/control-plane/internal/placement/sprm" + placementstore "github.com/dcm-project/control-plane/internal/placement/store" + policyopa "github.com/dcm-project/control-plane/internal/policy/opa" + policyservice "github.com/dcm-project/control-plane/internal/policy/service" + policystore "github.com/dcm-project/control-plane/internal/policy/store" + sprmsvc "github.com/dcm-project/control-plane/internal/sp/service/resource_manager" + spstore "github.com/dcm-project/control-plane/internal/sp/store" + "gorm.io/driver/postgres" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +// Run starts the dcm-gitops controller process. +func Run() int { + cfg, err := config.LoadConfig() + if err != nil { + slog.Error("Failed to load configuration", "error", err) + return 1 + } + + slogLogger := initLogger(cfg.LogLevel) + slog.SetDefault(slogLogger) + + slog.Info("dcm-gitops starting", + "db_type", cfg.Database.Type, + "db_host", cfg.Database.Hostname, + "db_name", cfg.Database.Name, + "git_work_dir", cfg.GitWorkDir, + "poll_interval", cfg.PollInterval, + ) + + db, err := openDB(cfg) + if err != nil { + slog.Error("Failed to initialize database", "error", err) + return 1 + } + defer func() { + if sqlDB, err := db.DB(); err == nil { + _ = sqlDB.Close() + } + }() + + // Migrate gitops schema + if err := db.AutoMigrate(&gitopsmodel.GitRepository{}, &gitopsmodel.ManagedInstance{}); err != nil { + slog.Error("Failed to migrate gitops schema", "error", err) + return 1 + } + + // Create stores + gitopsDataStore := gitopsstore.NewStore(db) + catalogDataStore := catalogstore.NewStore(db, slogLogger) + policyDataStore := policystore.NewStore(db) + placementDataStore := placementstore.NewStore(db) + spDataStore := spstore.NewStore(db) + + // Build catalog service (same wiring as dcm-server) + opaEngine := policyopa.NewEngine() + policySvc := policyservice.NewPolicyService(policyDataStore, opaEngine) + evaluationSvc := policyservice.NewEvaluationService(policyDataStore.Policy(), opaEngine) + if err := policySvc.CompileAll(context.Background()); err != nil { + slog.Error("Failed to compile policies on startup", "error", err) + return 1 + } + + spInstanceSvc := sprmsvc.NewInstanceService(spDataStore, nil, nil) + policyClient := placementpolicy.NewServiceClient(evaluationSvc) + sprmClient := placementsprm.NewServiceClient(spInstanceSvc) + placementSvc := placementservice.NewPlacementService(placementDataStore, policyClient, sprmClient) + + pmClient := catalogplacement.NewLocalClient(placementSvc, slogLogger) + + catalogSvc, err := catalogservice.NewService(catalogDataStore, pmClient, catalogconfig.SeedConfig{}, slogLogger) + if err != nil { + slog.Error("Failed to create catalog service", "error", err) + return 1 + } + + // Create git client and reconciler + gitClient := controller.NewGitClient(cfg.GitWorkDir) + reconciler := controller.NewReconciler(gitopsDataStore, catalogSvc.CatalogItemInstance(), catalogSvc.CatalogItem(), gitClient) + + // Create and start controller + pollInterval := time.Duration(cfg.PollInterval) * time.Second + ctrl := controller.NewController(reconciler, gitopsDataStore, pollInterval) + + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer cancel() + + ctrl.Start(ctx) + defer ctrl.Stop() + + slog.Info("dcm-gitops controller started") + + <-ctx.Done() + slog.Info("dcm-gitops shutting down") + return 0 +} + +func openDB(cfg *config.Config) (*gorm.DB, error) { + var dialector gorm.Dialector + switch cfg.Database.Type { + case "pgsql": + dsn := fmt.Sprintf("host=%s user=%s password=%s port=%s dbname=%s", + cfg.Database.Hostname, + cfg.Database.User, + cfg.Database.Password, + cfg.Database.Port, + cfg.Database.Name, + ) + dialector = postgres.Open(dsn) + case "sqlite": + dialector = sqlite.Open(cfg.Database.Name) + default: + return nil, fmt.Errorf("unsupported DB_TYPE %q", cfg.Database.Type) + } + + db, err := gorm.Open(dialector, &gorm.Config{ + Logger: logger.Default.LogMode(logger.Silent), + TranslateError: true, + }) + if err != nil { + return nil, fmt.Errorf("connect database: %w", err) + } + + if cfg.Database.Type == "sqlite" { + if err := db.Exec("PRAGMA foreign_keys = ON").Error; err != nil { + return nil, fmt.Errorf("enable sqlite foreign keys: %w", err) + } + } + + sqlDB, err := db.DB() + if err != nil { + return nil, fmt.Errorf("underlying db handle: %w", err) + } + sqlDB.SetMaxIdleConns(10) + sqlDB.SetMaxOpenConns(100) + sqlDB.SetConnMaxLifetime(time.Hour) + + return db, nil +} + +func initLogger(level string) *slog.Logger { + var logLevel slog.Level + switch strings.ToLower(level) { + case "debug": + logLevel = slog.LevelDebug + case "info": + logLevel = slog.LevelInfo + case "warn", "warning": + logLevel = slog.LevelWarn + case "error": + logLevel = slog.LevelError + default: + logLevel = slog.LevelInfo + } + return slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: logLevel})) +} diff --git a/internal/gitops/config/config.go b/internal/gitops/config/config.go new file mode 100644 index 0000000..d511ed2 --- /dev/null +++ b/internal/gitops/config/config.go @@ -0,0 +1,39 @@ +// Package config provides configuration loading for the dcm-gitops process. +package config + +import ( + "fmt" + + "github.com/kelseyhightower/envconfig" +) + +// Config holds configuration for the dcm-gitops process. +type Config struct { + Database DatabaseConfig + LogLevel string `envconfig:"LOG_LEVEL" default:"info"` + GitWorkDir string `envconfig:"GIT_WORK_DIR" default:"/tmp/dcm-gitops"` + // PollInterval is how often (in seconds) the controller reloads the list of repos from the DB. + PollInterval int `envconfig:"POLL_INTERVAL" default:"15"` +} + +// DatabaseConfig holds database connection settings. +type DatabaseConfig struct { + Type string `envconfig:"DB_TYPE" default:"pgsql"` + Hostname string `envconfig:"DB_HOST" default:"localhost"` + Port string `envconfig:"DB_PORT" default:"5432"` + Name string `envconfig:"DB_NAME" default:"control-plane"` + User string `envconfig:"DB_USER"` + Password string `envconfig:"DB_PASSWORD"` +} + +// LoadConfig reads configuration from environment variables. +func LoadConfig() (*Config, error) { + cfg := &Config{} + if err := envconfig.Process("", cfg); err != nil { + return nil, fmt.Errorf("load config: %w", err) + } + if cfg.PollInterval < 1 { + return nil, fmt.Errorf("POLL_INTERVAL must be >= 1 second, got %d", cfg.PollInterval) + } + return cfg, nil +} diff --git a/internal/gitops/controller/controller.go b/internal/gitops/controller/controller.go new file mode 100644 index 0000000..ab03f1b --- /dev/null +++ b/internal/gitops/controller/controller.go @@ -0,0 +1,129 @@ +// Package controller implements the GitOps reconciliation loop. +package controller + +import ( + "context" + "fmt" + "log/slog" + "sync" + "time" + + "github.com/dcm-project/control-plane/internal/gitops/store" + gitopsmodel "github.com/dcm-project/control-plane/internal/gitops/store/model" +) + +// Controller manages per-GitRepository reconciliation goroutines. +type Controller struct { + reconciler *Reconciler + store store.Store + pollInterval time.Duration // how often to reload repos from DB + stopCh chan struct{} + wg sync.WaitGroup + + // repoLocks prevents concurrent reconciliations of the same repository. + repoLocks map[string]*sync.Mutex + repoLocksMu sync.Mutex +} + +// NewController creates a new Controller. +// pollInterval controls how often the controller reloads the list of GitRepositories from the DB. +// It panics if pollInterval is not positive, since time.NewTicker requires a positive duration. +func NewController(reconciler *Reconciler, gitopsStore store.Store, pollInterval time.Duration) *Controller { + if pollInterval <= 0 { + panic(fmt.Sprintf("gitops controller: pollInterval must be positive, got %s", pollInterval)) + } + return &Controller{ + reconciler: reconciler, + store: gitopsStore, + pollInterval: pollInterval, + stopCh: make(chan struct{}), + repoLocks: make(map[string]*sync.Mutex), + } +} + +// Start begins the main controller loop. +func (c *Controller) Start(ctx context.Context) { + c.wg.Add(1) + go c.run(ctx) +} + +// Stop gracefully stops the controller and waits for all goroutines to finish. +func (c *Controller) Stop() { + close(c.stopCh) + c.wg.Wait() +} + +func (c *Controller) run(ctx context.Context) { + defer c.wg.Done() + + // Run immediately on start, then on ticker + c.reconcileAll(ctx) + + ticker := time.NewTicker(c.pollInterval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-c.stopCh: + return + case <-ticker.C: + c.reconcileAll(ctx) + } + } +} + +func (c *Controller) reconcileAll(ctx context.Context) { + repos, err := c.store.GitRepository().ListAll(ctx) + if err != nil { + slog.ErrorContext(ctx, "Failed to list git repositories", "error", err) + return + } + + for _, repo := range repos { + select { + case <-ctx.Done(): + return + case <-c.stopCh: + return + default: + } + + // Check if it's time to reconcile based on interval + if repo.LastSyncTime != nil { + nextSync := repo.LastSyncTime.Add(time.Duration(repo.IntervalSeconds) * time.Second) + if time.Now().Before(nextSync) { + continue + } + } + + c.reconcileOne(ctx, repo) + } +} + +func (c *Controller) reconcileOne(ctx context.Context, repo gitopsmodel.GitRepository) { + mu := c.repoLock(repo.ID) + if !mu.TryLock() { + slog.DebugContext(ctx, "Skipping repo, reconciliation already in progress", "repo_id", repo.ID) + return + } + defer mu.Unlock() + + if err := c.reconciler.Reconcile(ctx, repo); err != nil { + slog.ErrorContext(ctx, "Reconciliation failed", "repo_id", repo.ID, "error", err) + } +} + +// repoLock returns a per-repository mutex, creating one if it doesn't exist. +func (c *Controller) repoLock(repoID string) *sync.Mutex { + c.repoLocksMu.Lock() + defer c.repoLocksMu.Unlock() + + mu, ok := c.repoLocks[repoID] + if !ok { + mu = &sync.Mutex{} + c.repoLocks[repoID] = mu + } + return mu +} diff --git a/internal/gitops/controller/parser.go b/internal/gitops/controller/parser.go new file mode 100644 index 0000000..f7eb429 --- /dev/null +++ b/internal/gitops/controller/parser.go @@ -0,0 +1,173 @@ +package controller + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "gopkg.in/yaml.v3" +) + +// DesiredInstance represents a CatalogItemInstance parsed from a YAML file. +type DesiredInstance struct { + Name string `yaml:"-"` + ApiVersion string `yaml:"-"` + DisplayName string `yaml:"-"` + CatalogItemID string `yaml:"-"` + UserValues []DesiredUserValue `yaml:"-"` + SourceFile string `yaml:"-"` + Labels map[string]string `yaml:"-"` +} + +// DesiredUserValue represents a user value in a desired instance YAML. +type DesiredUserValue struct { + Resource string `yaml:"resource" json:"resource"` + Path string `yaml:"path" json:"path"` + Value any `yaml:"value" json:"value"` +} + +// yamlDocument is the raw YAML structure we parse. +type yamlDocument struct { + ApiVersion string `yaml:"apiVersion"` + Kind string `yaml:"kind"` + Metadata struct { + Name string `yaml:"name"` + Labels map[string]string `yaml:"labels,omitempty"` + } `yaml:"metadata"` + Spec struct { + DisplayName string `yaml:"display_name"` + CatalogItemID string `yaml:"catalog_item_id"` + UserValues []DesiredUserValue `yaml:"user_values"` + } `yaml:"spec"` +} + +// ParseError contains a parse error and the file that caused it. +type ParseError struct { + File string + Err error +} + +func (e *ParseError) Error() string { + return fmt.Sprintf("%s: %s", e.File, e.Err.Error()) +} + +// ParseResult contains the successfully parsed instances and any errors. +type ParseResult struct { + Instances []DesiredInstance + Errors []ParseError +} + +// ParseCatalogItemInstances reads YAML files directly at the given path (non-recursive) +// and parses them as CatalogItemInstance definitions. +// Files are processed in alphabetical order; for duplicate metadata.name, +// the last file alphabetically wins. +func ParseCatalogItemInstances(dir, specPath string) ParseResult { + result := ParseResult{} + + fullPath := filepath.Join(dir, specPath) + + // Verify the resolved path stays within the repo directory. + absDir, err := filepath.Abs(dir) + if err != nil { + result.Errors = append(result.Errors, ParseError{File: dir, Err: fmt.Errorf("resolve repo dir: %w", err)}) + return result + } + absFull, err := filepath.Abs(fullPath) + if err != nil { + result.Errors = append(result.Errors, ParseError{File: fullPath, Err: fmt.Errorf("resolve spec path: %w", err)}) + return result + } + rel, err := filepath.Rel(absDir, absFull) + if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + result.Errors = append(result.Errors, ParseError{File: specPath, Err: fmt.Errorf("spec.path escapes repository root")}) + return result + } + + entries, err := os.ReadDir(fullPath) + if err != nil { + result.Errors = append(result.Errors, ParseError{File: fullPath, Err: err}) + return result + } + + // Collect yaml files and sort alphabetically + var yamlFiles []string + for _, entry := range entries { + if entry.IsDir() { + continue + } + name := entry.Name() + ext := strings.ToLower(filepath.Ext(name)) + if ext == ".yaml" || ext == ".yml" { + yamlFiles = append(yamlFiles, name) + } + } + sort.Strings(yamlFiles) + + // Parse files; last-wins for duplicate names + seen := make(map[string]int) // name -> index in result.Instances + for _, filename := range yamlFiles { + filePath := filepath.Join(fullPath, filename) + data, err := os.ReadFile(filePath) + if err != nil { + result.Errors = append(result.Errors, ParseError{File: filename, Err: err}) + continue + } + + var doc yamlDocument + if err := yaml.Unmarshal(data, &doc); err != nil { + result.Errors = append(result.Errors, ParseError{File: filename, Err: err}) + continue + } + + if doc.Kind != "CatalogItemInstance" { + result.Errors = append(result.Errors, ParseError{ + File: filename, + Err: fmt.Errorf("unexpected kind %q, expected CatalogItemInstance", doc.Kind), + }) + continue + } + + if doc.Metadata.Name == "" { + result.Errors = append(result.Errors, ParseError{ + File: filename, + Err: fmt.Errorf("metadata.name is required"), + }) + continue + } + + if doc.Spec.CatalogItemID == "" { + result.Errors = append(result.Errors, ParseError{ + File: filename, + Err: fmt.Errorf("spec.catalog_item_id is required"), + }) + continue + } + + displayName := doc.Spec.DisplayName + if displayName == "" { + displayName = doc.Metadata.Name + } + + instance := DesiredInstance{ + Name: doc.Metadata.Name, + ApiVersion: doc.ApiVersion, + DisplayName: displayName, + CatalogItemID: doc.Spec.CatalogItemID, + UserValues: doc.Spec.UserValues, + SourceFile: filepath.Join(specPath, filename), + Labels: doc.Metadata.Labels, + } + + if idx, exists := seen[doc.Metadata.Name]; exists { + // Last file alphabetically wins + result.Instances[idx] = instance + } else { + seen[doc.Metadata.Name] = len(result.Instances) + result.Instances = append(result.Instances, instance) + } + } + + return result +} diff --git a/internal/gitops/controller/parser_test.go b/internal/gitops/controller/parser_test.go new file mode 100644 index 0000000..481c756 --- /dev/null +++ b/internal/gitops/controller/parser_test.go @@ -0,0 +1,249 @@ +package controller_test + +import ( + "os" + "path/filepath" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/dcm-project/control-plane/internal/gitops/controller" +) + +var _ = Describe("ParseCatalogItemInstances", func() { + var tmpDir string + + BeforeEach(func() { + tmpDir = GinkgoT().TempDir() + }) + + writeFile := func(name, content string) { + err := os.MkdirAll(filepath.Join(tmpDir, "apps"), 0o755) + Expect(err).NotTo(HaveOccurred()) + err = os.WriteFile(filepath.Join(tmpDir, "apps", name), []byte(content), 0o644) + Expect(err).NotTo(HaveOccurred()) + } + + It("parses a valid CatalogItemInstance YAML", func() { + writeFile("webserver.yaml", ` +apiVersion: v1alpha1 +kind: CatalogItemInstance +metadata: + name: my-webserver +spec: + display_name: My Webserver + catalog_item_id: small-vm + user_values: + - resource: app + path: vcpu.count + value: 4 +`) + result := controller.ParseCatalogItemInstances(tmpDir, "apps") + Expect(result.Errors).To(BeEmpty()) + Expect(result.Instances).To(HaveLen(1)) + Expect(result.Instances[0].Name).To(Equal("my-webserver")) + Expect(result.Instances[0].DisplayName).To(Equal("My Webserver")) + Expect(result.Instances[0].CatalogItemID).To(Equal("small-vm")) + Expect(result.Instances[0].UserValues).To(HaveLen(1)) + Expect(result.Instances[0].UserValues[0].Path).To(Equal("vcpu.count")) + Expect(result.Instances[0].SourceFile).To(Equal("apps/webserver.yaml")) + }) + + It("parses multiple YAML files in alphabetical order", func() { + writeFile("beta.yaml", ` +apiVersion: v1alpha1 +kind: CatalogItemInstance +metadata: + name: beta-app +spec: + catalog_item_id: small-vm + user_values: [] +`) + writeFile("alpha.yaml", ` +apiVersion: v1alpha1 +kind: CatalogItemInstance +metadata: + name: alpha-app +spec: + catalog_item_id: small-vm + user_values: [] +`) + result := controller.ParseCatalogItemInstances(tmpDir, "apps") + Expect(result.Errors).To(BeEmpty()) + Expect(result.Instances).To(HaveLen(2)) + Expect(result.Instances[0].Name).To(Equal("alpha-app")) + Expect(result.Instances[1].Name).To(Equal("beta-app")) + }) + + It("last file alphabetically wins for duplicate names", func() { + writeFile("alpha.yaml", ` +apiVersion: v1alpha1 +kind: CatalogItemInstance +metadata: + name: same-name +spec: + catalog_item_id: old-item + user_values: [] +`) + writeFile("zebra.yaml", ` +apiVersion: v1alpha1 +kind: CatalogItemInstance +metadata: + name: same-name +spec: + catalog_item_id: new-item + user_values: [] +`) + result := controller.ParseCatalogItemInstances(tmpDir, "apps") + Expect(result.Errors).To(BeEmpty()) + Expect(result.Instances).To(HaveLen(1)) + Expect(result.Instances[0].CatalogItemID).To(Equal("new-item")) + }) + + It("skips invalid YAML and reports errors", func() { + writeFile("bad.yaml", `this is not valid yaml: [`) + writeFile("good.yaml", ` +apiVersion: v1alpha1 +kind: CatalogItemInstance +metadata: + name: good-app +spec: + catalog_item_id: small-vm + user_values: [] +`) + result := controller.ParseCatalogItemInstances(tmpDir, "apps") + Expect(result.Errors).To(HaveLen(1)) + Expect(result.Errors[0].File).To(Equal("bad.yaml")) + Expect(result.Instances).To(HaveLen(1)) + Expect(result.Instances[0].Name).To(Equal("good-app")) + }) + + It("skips files with wrong kind", func() { + writeFile("policy.yaml", ` +apiVersion: v1alpha1 +kind: Policy +metadata: + name: my-policy +spec: + catalog_item_id: something + user_values: [] +`) + result := controller.ParseCatalogItemInstances(tmpDir, "apps") + Expect(result.Errors).To(HaveLen(1)) + Expect(result.Errors[0].File).To(Equal("policy.yaml")) + Expect(result.Instances).To(BeEmpty()) + }) + + It("skips files missing metadata.name", func() { + writeFile("noname.yaml", ` +apiVersion: v1alpha1 +kind: CatalogItemInstance +metadata: + name: "" +spec: + catalog_item_id: small-vm + user_values: [] +`) + result := controller.ParseCatalogItemInstances(tmpDir, "apps") + Expect(result.Errors).To(HaveLen(1)) + Expect(result.Instances).To(BeEmpty()) + }) + + It("skips files missing spec.catalog_item_id", func() { + writeFile("noid.yaml", ` +apiVersion: v1alpha1 +kind: CatalogItemInstance +metadata: + name: my-app +spec: + user_values: [] +`) + result := controller.ParseCatalogItemInstances(tmpDir, "apps") + Expect(result.Errors).To(HaveLen(1)) + Expect(result.Instances).To(BeEmpty()) + }) + + It("ignores non-YAML files", func() { + writeFile("readme.txt", "This is not YAML") + writeFile("app.yaml", ` +apiVersion: v1alpha1 +kind: CatalogItemInstance +metadata: + name: my-app +spec: + catalog_item_id: small-vm + user_values: [] +`) + result := controller.ParseCatalogItemInstances(tmpDir, "apps") + Expect(result.Errors).To(BeEmpty()) + Expect(result.Instances).To(HaveLen(1)) + }) + + It("ignores subdirectories", func() { + // Create a subdirectory with a YAML file + subdir := filepath.Join(tmpDir, "apps", "subdir") + err := os.MkdirAll(subdir, 0o755) + Expect(err).NotTo(HaveOccurred()) + err = os.WriteFile(filepath.Join(subdir, "nested.yaml"), []byte(` +apiVersion: v1alpha1 +kind: CatalogItemInstance +metadata: + name: nested-app +spec: + catalog_item_id: small-vm + user_values: [] +`), 0o644) + Expect(err).NotTo(HaveOccurred()) + + writeFile("root.yaml", ` +apiVersion: v1alpha1 +kind: CatalogItemInstance +metadata: + name: root-app +spec: + catalog_item_id: small-vm + user_values: [] +`) + result := controller.ParseCatalogItemInstances(tmpDir, "apps") + Expect(result.Errors).To(BeEmpty()) + Expect(result.Instances).To(HaveLen(1)) + Expect(result.Instances[0].Name).To(Equal("root-app")) + }) + + It("supports .yml extension", func() { + writeFile("app.yml", ` +apiVersion: v1alpha1 +kind: CatalogItemInstance +metadata: + name: yml-app +spec: + catalog_item_id: small-vm + user_values: [] +`) + result := controller.ParseCatalogItemInstances(tmpDir, "apps") + Expect(result.Errors).To(BeEmpty()) + Expect(result.Instances).To(HaveLen(1)) + Expect(result.Instances[0].Name).To(Equal("yml-app")) + }) + + It("uses metadata.name as display_name when display_name is empty", func() { + writeFile("app.yaml", ` +apiVersion: v1alpha1 +kind: CatalogItemInstance +metadata: + name: my-app +spec: + catalog_item_id: small-vm + user_values: [] +`) + result := controller.ParseCatalogItemInstances(tmpDir, "apps") + Expect(result.Errors).To(BeEmpty()) + Expect(result.Instances[0].DisplayName).To(Equal("my-app")) + }) + + It("returns error when path does not exist", func() { + result := controller.ParseCatalogItemInstances(tmpDir, "nonexistent") + Expect(result.Errors).To(HaveLen(1)) + Expect(result.Instances).To(BeEmpty()) + }) +}) diff --git a/internal/gitops/controller/reconciler.go b/internal/gitops/controller/reconciler.go new file mode 100644 index 0000000..3cff957 --- /dev/null +++ b/internal/gitops/controller/reconciler.go @@ -0,0 +1,223 @@ +package controller + +import ( + "context" + "fmt" + "log/slog" + "time" + + catalogv1alpha1 "github.com/dcm-project/control-plane/api/catalog/v1alpha1" + catalogservice "github.com/dcm-project/control-plane/internal/catalog/service" + "github.com/dcm-project/control-plane/internal/gitops/store" + "github.com/dcm-project/control-plane/internal/gitops/store/model" +) + +// Reconciler handles the reconciliation of a single GitRepository. +type Reconciler struct { + gitopsStore store.Store + catalogSvc catalogservice.CatalogItemInstanceService + catalogItemSvc catalogservice.CatalogItemService + gitClient GitOperations +} + +// NewReconciler creates a new Reconciler. +func NewReconciler(gitopsStore store.Store, catalogSvc catalogservice.CatalogItemInstanceService, catalogItemSvc catalogservice.CatalogItemService, gitClient GitOperations) *Reconciler { + return &Reconciler{ + gitopsStore: gitopsStore, + catalogSvc: catalogSvc, + catalogItemSvc: catalogItemSvc, + gitClient: gitClient, + } +} + +// gitOperationTimeout is the maximum time allowed for git clone/fetch operations. +const gitOperationTimeout = 2 * time.Minute + +// Reconcile performs a single reconciliation cycle for the given GitRepository. +func (r *Reconciler) Reconcile(ctx context.Context, repo model.GitRepository) error { + slog.InfoContext(ctx, "Reconciling git repository", "id", repo.ID, "url", repo.URL) + + // 1. Clone or fetch (with timeout to prevent hanging on unresponsive servers) + gitCtx, gitCancel := context.WithTimeout(ctx, gitOperationTimeout) + defer gitCancel() + + latestCommit, err := r.gitClient.CloneOrFetch(gitCtx, repo.URL, repo.Branch, repo.ID) + if err != nil { + r.setError(ctx, repo.ID, fmt.Sprintf("git fetch failed: %s", err.Error())) + return fmt.Errorf("git fetch: %w", err) + } + + // 2. Check if commit changed + if latestCommit == repo.LastSyncedCommit { + slog.DebugContext(ctx, "No new commits", "id", repo.ID, "commit", latestCommit) + r.setSynced(ctx, repo.ID, latestCommit) + return nil + } + + // 3. Parse YAML files at spec.path + workDir := r.gitClient.WorkDir(repo.ID) + parseResult := ParseCatalogItemInstances(workDir, repo.Path) + + for _, pe := range parseResult.Errors { + slog.WarnContext(ctx, "Parse error", "id", repo.ID, "file", pe.File, "error", pe.Err) + } + + // Abort reconciliation if all files failed to parse — this prevents + // mass-deletion of existing instances due to transient parse failures. + if len(parseResult.Instances) == 0 && len(parseResult.Errors) > 0 { + r.setError(ctx, repo.ID, fmt.Sprintf("all %d YAML files failed to parse, aborting reconciliation", len(parseResult.Errors))) + return fmt.Errorf("all %d YAML files failed to parse", len(parseResult.Errors)) + } + + // 4. Get existing git-managed instance IDs for this repo + managedIDs, err := r.gitopsStore.ManagedInstance().ListByRepo(ctx, repo.ID) + if err != nil { + r.setError(ctx, repo.ID, fmt.Sprintf("failed to list managed instances: %s", err.Error())) + return fmt.Errorf("list managed instances: %w", err) + } + + // 5. Classify: create / delete + desiredByName := make(map[string]DesiredInstance, len(parseResult.Instances)) + for _, d := range parseResult.Instances { + desiredByName[d.Name] = d + } + + existingByID := make(map[string]struct{}, len(managedIDs)) + for _, id := range managedIDs { + existingByID[id] = struct{}{} + } + + var toCreate []DesiredInstance + for name, desired := range desiredByName { + if _, exists := existingByID[name]; !exists { + toCreate = append(toCreate, desired) + } + } + + var toDelete []string // IDs to delete + for id := range existingByID { + if _, exists := desiredByName[id]; !exists { + toDelete = append(toDelete, id) + slog.InfoContext(ctx, "Will delete instance removed from Git", "id", repo.ID, "instance_id", id) + } + } + + if len(toCreate) == 0 && len(toDelete) == 0 { + slog.InfoContext(ctx, "No lifecycle changes detected", "id", repo.ID) + r.setSynced(ctx, repo.ID, latestCommit) + return nil + } + + // 6. Set status IN_PROGRESS + _ = r.gitopsStore.GitRepository().UpdateSyncStatus(ctx, repo.ID, "IN_PROGRESS", "Applying lifecycle changes", repo.LastSyncedCommit) + + // 7. Apply creates + var reconcileErrors []string + for _, desired := range toCreate { + slog.InfoContext(ctx, "Creating instance from Git", "id", repo.ID, "instance_name", desired.Name) + if err := r.createInstance(ctx, repo.ID, latestCommit, desired); err != nil { + slog.ErrorContext(ctx, "Failed to create instance", "id", repo.ID, "instance_name", desired.Name, "error", err) + reconcileErrors = append(reconcileErrors, fmt.Sprintf("create %s: %s", desired.Name, err.Error())) + continue + } + if err := r.gitopsStore.ManagedInstance().Add(ctx, repo.ID, desired.Name); err != nil { + slog.ErrorContext(ctx, "Failed to record managed instance", "id", repo.ID, "instance_name", desired.Name, "error", err) + reconcileErrors = append(reconcileErrors, fmt.Sprintf("record %s: %s", desired.Name, err.Error())) + } + } + + // 8. Apply deletes + for _, instanceID := range toDelete { + slog.InfoContext(ctx, "Deleting instance removed from Git", "id", repo.ID, "instance_id", instanceID) + if err := r.catalogSvc.Delete(ctx, instanceID); err != nil { + slog.ErrorContext(ctx, "Failed to delete instance", "id", repo.ID, "instance_id", instanceID, "error", err) + reconcileErrors = append(reconcileErrors, fmt.Sprintf("delete %s: %s", instanceID, err.Error())) + continue + } + if err := r.gitopsStore.ManagedInstance().Remove(ctx, repo.ID, instanceID); err != nil { + slog.ErrorContext(ctx, "Failed to remove managed instance record", "id", repo.ID, "instance_id", instanceID, "error", err) + reconcileErrors = append(reconcileErrors, fmt.Sprintf("unrecord %s: %s", instanceID, err.Error())) + } + } + + // 9. Update status + if len(reconcileErrors) > 0 { + r.setError(ctx, repo.ID, fmt.Sprintf("reconciliation errors: %v", reconcileErrors)) + return fmt.Errorf("reconciliation had %d errors", len(reconcileErrors)) + } + + r.setSynced(ctx, repo.ID, latestCommit) + slog.InfoContext(ctx, "Reconciliation complete", "id", repo.ID, "commit", latestCommit, + "created", len(toCreate), "deleted", len(toDelete)) + return nil +} + +func (r *Reconciler) createInstance(ctx context.Context, repoID, latestCommit string, desired DesiredInstance) error { + apiVersion := desired.ApiVersion + if apiVersion == "" { + apiVersion = "v1alpha1" + } + + // Look up catalog item to get all resource names + catalogItem, err := r.catalogItemSvc.Get(ctx, desired.CatalogItemID) + if err != nil { + return fmt.Errorf("get catalog item %s: %w", desired.CatalogItemID, err) + } + + // Merge gitops labels with user-defined labels from YAML metadata + mergedLabels := map[string]string{ + "gitops.dcm.io/repository": repoID, + "gitops.dcm.io/commit": latestCommit, + } + for k, v := range desired.Labels { + mergedLabels[k] = v + } + labelValue := map[string]interface{}{ + "labels": mergedLabels, + } + + // Inject labels for every resource in the catalog item + var userValues []catalogv1alpha1.UserValue + for _, resource := range catalogItem.Spec.Resources { + userValues = append(userValues, catalogv1alpha1.UserValue{ + Resource: resource.Name, + Path: "metadata", + Value: labelValue, + }) + } + + // Append the user's original values + for _, uv := range desired.UserValues { + userValues = append(userValues, catalogv1alpha1.UserValue{ + Resource: uv.Resource, + Path: uv.Path, + Value: uv.Value, + }) + } + + req := &catalogservice.CreateCatalogItemInstanceRequest{ + ApiVersion: apiVersion, + DisplayName: desired.DisplayName, + Spec: catalogv1alpha1.CatalogItemInstanceSpec{ + CatalogItemId: desired.CatalogItemID, + UserValues: userValues, + }, + } + // Use the desired instance name as the ID + req.ID = &desired.Name + + _, err = r.catalogSvc.Create(ctx, req) + return err +} + +func (r *Reconciler) setSynced(ctx context.Context, repoID, commit string) { + if err := r.gitopsStore.GitRepository().UpdateSyncStatus(ctx, repoID, "SYNCED", "", commit); err != nil { + slog.ErrorContext(ctx, "Failed to update sync status to SYNCED", "id", repoID, "error", err) + } +} + +func (r *Reconciler) setError(ctx context.Context, repoID, message string) { + if err := r.gitopsStore.GitRepository().UpdateSyncStatus(ctx, repoID, "ERROR", message, ""); err != nil { + slog.ErrorContext(ctx, "Failed to update sync status to ERROR", "id", repoID, "error", err) + } +} diff --git a/internal/gitops/controller/repository.go b/internal/gitops/controller/repository.go new file mode 100644 index 0000000..7977994 --- /dev/null +++ b/internal/gitops/controller/repository.go @@ -0,0 +1,117 @@ +package controller + +import ( + "context" + "fmt" + "os" + "path/filepath" + "regexp" + + "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/config" + "github.com/go-git/go-git/v5/plumbing" +) + +// validRepoID matches DNS-1123 label format used for git repository IDs. +var validRepoID = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$`) + +// GitOperations abstracts git operations for testability. +type GitOperations interface { + // CloneOrFetch clones the repo if not present, or fetches and resets if it exists. + // Returns the latest commit SHA on the specified branch. + CloneOrFetch(ctx context.Context, url, branch, repoID string) (latestCommit string, err error) + + // WorkDir returns the path to the cloned repo for a given repoID. + WorkDir(repoID string) string +} + +// GitClient implements GitOperations using go-git. +type GitClient struct { + baseDir string +} + +// NewGitClient creates a GitClient that stores cloned repos under baseDir. +func NewGitClient(baseDir string) *GitClient { + return &GitClient{baseDir: baseDir} +} + +func (g *GitClient) WorkDir(repoID string) string { + return filepath.Join(g.baseDir, repoID) +} + +func (g *GitClient) CloneOrFetch(ctx context.Context, repoURL, branch, repoID string) (string, error) { + if !validRepoID.MatchString(repoID) { + return "", fmt.Errorf("invalid repo ID %q: must match DNS-1123 label format", repoID) + } + + dir := g.WorkDir(repoID) + refName := plumbing.NewBranchReferenceName(branch) + + if _, err := os.Stat(filepath.Join(dir, ".git")); os.IsNotExist(err) { + return g.cloneRepo(ctx, repoURL, dir, refName) + } + return g.fetchAndReset(ctx, branch, dir) +} + +func (g *GitClient) cloneRepo(ctx context.Context, repoURL, dir string, refName plumbing.ReferenceName) (string, error) { + if err := os.MkdirAll(filepath.Dir(dir), 0o755); err != nil { + return "", fmt.Errorf("create parent dir: %w", err) + } + + repo, err := git.PlainCloneContext(ctx, dir, false, &git.CloneOptions{ + URL: repoURL, + ReferenceName: refName, + SingleBranch: true, + Depth: 1, + }) + if err != nil { + return "", fmt.Errorf("git clone: %w", err) + } + + return headCommit(repo) +} + +func (g *GitClient) fetchAndReset(ctx context.Context, branch, dir string) (string, error) { + repo, err := git.PlainOpen(dir) + if err != nil { + return "", fmt.Errorf("open repo: %w", err) + } + + err = repo.FetchContext(ctx, &git.FetchOptions{ + RemoteName: "origin", + RefSpecs: []config.RefSpec{config.RefSpec(fmt.Sprintf("+refs/heads/%s:refs/remotes/origin/%s", branch, branch))}, + Depth: 1, + Force: true, + }) + if err != nil && err != git.NoErrAlreadyUpToDate { + return "", fmt.Errorf("git fetch: %w", err) + } + + // Resolve origin/ and reset HEAD to it + remoteRef, err := repo.Reference(plumbing.NewRemoteReferenceName("origin", branch), true) + if err != nil { + return "", fmt.Errorf("resolve remote ref: %w", err) + } + + wt, err := repo.Worktree() + if err != nil { + return "", fmt.Errorf("worktree: %w", err) + } + + if err := wt.Reset(&git.ResetOptions{ + Commit: remoteRef.Hash(), + Mode: git.HardReset, + }); err != nil { + return "", fmt.Errorf("git reset: %w", err) + } + + return remoteRef.Hash().String(), nil +} + +func headCommit(repo *git.Repository) (string, error) { + head, err := repo.Head() + if err != nil { + return "", fmt.Errorf("resolve HEAD: %w", err) + } + return head.Hash().String(), nil +} diff --git a/internal/gitops/controller/suite_test.go b/internal/gitops/controller/suite_test.go new file mode 100644 index 0000000..c6da3d2 --- /dev/null +++ b/internal/gitops/controller/suite_test.go @@ -0,0 +1,13 @@ +package controller_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestController(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "GitOps Controller Suite") +} diff --git a/internal/gitops/handlers/v1alpha1/errors.go b/internal/gitops/handlers/v1alpha1/errors.go new file mode 100644 index 0000000..ca9c248 --- /dev/null +++ b/internal/gitops/handlers/v1alpha1/errors.go @@ -0,0 +1,105 @@ +package v1alpha1 + +import ( + "errors" + + v1alpha1 "github.com/dcm-project/control-plane/api/gitops/v1alpha1" + "github.com/dcm-project/control-plane/internal/gitops/api/server" + "github.com/dcm-project/control-plane/internal/gitops/service" +) + +func buildError(status int32, errType v1alpha1.ErrorType, title, detail string) v1alpha1.Error { + return v1alpha1.Error{ + Status: status, + Type: errType, + Title: title, + Detail: &detail, + } +} + +func handleListError(err error) server.ListGitRepositoriesResponseObject { + if errors.Is(err, service.ErrInvalidArgument) { + return server.ListGitRepositories400JSONResponse{ + BadRequestJSONResponse: server.BadRequestJSONResponse(buildError( + 400, v1alpha1.INVALIDARGUMENT, "Invalid request", err.Error(), + )), + } + } + return server.ListGitRepositories500JSONResponse{ + InternalServerErrorJSONResponse: server.InternalServerErrorJSONResponse(buildError( + 500, v1alpha1.INTERNAL, "Internal server error", err.Error(), + )), + } +} + +func handleCreateError(err error) server.CreateGitRepositoryResponseObject { + switch { + case errors.Is(err, service.ErrInvalidArgument): + return server.CreateGitRepository400JSONResponse(buildError( + 400, v1alpha1.INVALIDARGUMENT, "Invalid request", err.Error(), + )) + case errors.Is(err, service.ErrAlreadyExists): + return server.CreateGitRepository409JSONResponse{ + AlreadyExistsJSONResponse: server.AlreadyExistsJSONResponse(buildError( + 409, v1alpha1.ALREADYEXISTS, "Resource already exists", err.Error(), + )), + } + default: + return server.CreateGitRepository500JSONResponse{ + InternalServerErrorJSONResponse: server.InternalServerErrorJSONResponse(buildError( + 500, v1alpha1.INTERNAL, "Internal server error", err.Error(), + )), + } + } +} + +func handleGetError(err error) server.GetGitRepositoryResponseObject { + if errors.Is(err, service.ErrNotFound) { + return server.GetGitRepository404JSONResponse{ + NotFoundJSONResponse: server.NotFoundJSONResponse(buildError( + 404, v1alpha1.NOTFOUND, "Resource not found", err.Error(), + )), + } + } + return server.GetGitRepository500JSONResponse{ + InternalServerErrorJSONResponse: server.InternalServerErrorJSONResponse(buildError( + 500, v1alpha1.INTERNAL, "Internal server error", err.Error(), + )), + } +} + +func handleUpdateError(err error) server.UpdateGitRepositoryResponseObject { + switch { + case errors.Is(err, service.ErrInvalidArgument): + return server.UpdateGitRepository400JSONResponse(buildError( + 400, v1alpha1.INVALIDARGUMENT, "Invalid request", err.Error(), + )) + case errors.Is(err, service.ErrNotFound): + return server.UpdateGitRepository404JSONResponse{ + NotFoundJSONResponse: server.NotFoundJSONResponse(buildError( + 404, v1alpha1.NOTFOUND, "Resource not found", err.Error(), + )), + } + default: + return server.UpdateGitRepository500JSONResponse{ + InternalServerErrorJSONResponse: server.InternalServerErrorJSONResponse(buildError( + 500, v1alpha1.INTERNAL, "Internal server error", err.Error(), + )), + } + } +} + +func handleDeleteError(err error) server.DeleteGitRepositoryResponseObject { + if errors.Is(err, service.ErrNotFound) { + return server.DeleteGitRepository404JSONResponse{ + NotFoundJSONResponse: server.NotFoundJSONResponse(buildError( + 404, v1alpha1.NOTFOUND, "Resource not found", err.Error(), + )), + } + } + return server.DeleteGitRepository500JSONResponse{ + InternalServerErrorJSONResponse: server.InternalServerErrorJSONResponse(buildError( + 500, v1alpha1.INTERNAL, "Internal server error", err.Error(), + )), + } +} diff --git a/internal/gitops/handlers/v1alpha1/handler.go b/internal/gitops/handlers/v1alpha1/handler.go new file mode 100644 index 0000000..e0bd6a3 --- /dev/null +++ b/internal/gitops/handlers/v1alpha1/handler.go @@ -0,0 +1,80 @@ +// Package v1alpha1 handles v1alpha1 API requests for GitRepository CRUD operations. +package v1alpha1 + +import ( + "context" + "log/slog" + + v1alpha1 "github.com/dcm-project/control-plane/api/gitops/v1alpha1" + "github.com/dcm-project/control-plane/internal/gitops/api/server" + "github.com/dcm-project/control-plane/internal/gitops/service" +) + +type Handler struct { + service service.GitRepositoryService +} + +// Ensure Handler implements StrictServerInterface +var _ server.StrictServerInterface = (*Handler)(nil) + +func NewHandler(svc service.GitRepositoryService) *Handler { + return &Handler{service: svc} +} + +func (h *Handler) ListGitRepositories(ctx context.Context, request server.ListGitRepositoriesRequestObject) (server.ListGitRepositoriesResponseObject, error) { + result, err := h.service.List(ctx, request.Params.PageToken, request.Params.MaxPageSize) + if err != nil { + return handleListError(err), nil + } + return server.ListGitRepositories200JSONResponse(*result), nil +} + +func (h *Handler) CreateGitRepository(ctx context.Context, request server.CreateGitRepositoryRequestObject) (server.CreateGitRepositoryResponseObject, error) { + if request.Body == nil { + return server.CreateGitRepository400JSONResponse(buildError( + 400, v1alpha1.INVALIDARGUMENT, "Invalid request body", "Request body is required", + )), nil + } + + created, err := h.service.Create(ctx, *request.Body, request.Params.Id) + if err != nil { + return handleCreateError(err), nil + } + + slog.InfoContext(ctx, "GitRepository created", "id", *created.Uid) + return server.CreateGitRepository201JSONResponse(*created), nil +} + +func (h *Handler) GetGitRepository(ctx context.Context, request server.GetGitRepositoryRequestObject) (server.GetGitRepositoryResponseObject, error) { + repo, err := h.service.Get(ctx, request.GitRepositoryId) + if err != nil { + return handleGetError(err), nil + } + return server.GetGitRepository200JSONResponse(*repo), nil +} + +func (h *Handler) UpdateGitRepository(ctx context.Context, request server.UpdateGitRepositoryRequestObject) (server.UpdateGitRepositoryResponseObject, error) { + if request.Body == nil { + return server.UpdateGitRepository400JSONResponse(buildError( + 400, v1alpha1.INVALIDARGUMENT, "Invalid request body", "Request body is required", + )), nil + } + + updated, err := h.service.Update(ctx, request.GitRepositoryId, *request.Body) + if err != nil { + return handleUpdateError(err), nil + } + + slog.InfoContext(ctx, "GitRepository updated", "id", request.GitRepositoryId) + return server.UpdateGitRepository200JSONResponse(*updated), nil +} + +func (h *Handler) DeleteGitRepository(ctx context.Context, request server.DeleteGitRepositoryRequestObject) (server.DeleteGitRepositoryResponseObject, error) { + err := h.service.Delete(ctx, request.GitRepositoryId) + if err != nil { + return handleDeleteError(err), nil + } + + slog.InfoContext(ctx, "GitRepository deleted", "id", request.GitRepositoryId) + return server.DeleteGitRepository204Response{}, nil +} diff --git a/internal/gitops/service/service.go b/internal/gitops/service/service.go new file mode 100644 index 0000000..b25e808 --- /dev/null +++ b/internal/gitops/service/service.go @@ -0,0 +1,250 @@ +// Package service implements the GitRepository business logic. +package service + +import ( + "context" + "errors" + "fmt" + "net/url" + "path/filepath" + "strings" + + v1alpha1 "github.com/dcm-project/control-plane/api/gitops/v1alpha1" + "github.com/dcm-project/control-plane/internal/gitops/store" + "github.com/dcm-project/control-plane/internal/gitops/store/model" + "github.com/google/uuid" +) + +const ( + minIntervalSeconds = 10 + maxIntervalSeconds = 86400 +) + +var ( + ErrNotFound = errors.New("git repository not found") + ErrAlreadyExists = errors.New("git repository already exists") + ErrInvalidArgument = errors.New("invalid argument") +) + +type GitRepositoryService interface { + List(ctx context.Context, pageToken *string, maxPageSize *int32) (*v1alpha1.GitRepositoryList, error) + Get(ctx context.Context, id string) (*v1alpha1.GitRepository, error) + Create(ctx context.Context, req v1alpha1.GitRepository, clientID *string) (*v1alpha1.GitRepository, error) + Update(ctx context.Context, id string, req v1alpha1.GitRepository) (*v1alpha1.GitRepository, error) + Delete(ctx context.Context, id string) error +} + +type gitRepositoryService struct { + store store.Store +} + +func NewGitRepositoryService(store store.Store) GitRepositoryService { + return &gitRepositoryService{store: store} +} + +func (s *gitRepositoryService) List(ctx context.Context, pageToken *string, maxPageSize *int32) (*v1alpha1.GitRepositoryList, error) { + opts := &store.GitRepositoryListOptions{ + PageToken: pageToken, + } + if maxPageSize != nil { + opts.PageSize = int(*maxPageSize) + } + + result, err := s.store.GitRepository().List(ctx, opts) + if err != nil { + return nil, err + } + + apiTypes := make([]v1alpha1.GitRepository, len(result.GitRepositories)) + for i, m := range result.GitRepositories { + apiTypes[i] = modelToAPI(&m) + } + + return &v1alpha1.GitRepositoryList{ + Results: apiTypes, + NextPageToken: result.NextPageToken, + }, nil +} + +func (s *gitRepositoryService) Get(ctx context.Context, id string) (*v1alpha1.GitRepository, error) { + repo, err := s.store.GitRepository().Get(ctx, id) + if err != nil { + return nil, mapStoreError(err) + } + result := modelToAPI(repo) + return &result, nil +} + +func (s *gitRepositoryService) Create(ctx context.Context, req v1alpha1.GitRepository, clientID *string) (*v1alpha1.GitRepository, error) { + id := getOrGenerateID(clientID) + + if err := validateGitRepository(req); err != nil { + return nil, err + } + + m := apiToModel(id, req) + created, err := s.store.GitRepository().Create(ctx, m) + if err != nil { + return nil, mapStoreError(err) + } + result := modelToAPI(created) + return &result, nil +} + +func (s *gitRepositoryService) Update(ctx context.Context, id string, req v1alpha1.GitRepository) (*v1alpha1.GitRepository, error) { + if err := validateGitRepository(req); err != nil { + return nil, err + } + + m := apiToModel(id, req) + updated, err := s.store.GitRepository().Update(ctx, m) + if err != nil { + return nil, mapStoreError(err) + } + result := modelToAPI(updated) + return &result, nil +} + +func (s *gitRepositoryService) Delete(ctx context.Context, id string) error { + err := s.store.GitRepository().Delete(ctx, id) + if err != nil { + return mapStoreError(err) + } + return nil +} + +func validateGitRepository(req v1alpha1.GitRepository) error { + if req.DisplayName == "" { + return fmt.Errorf("%w: display_name is required", ErrInvalidArgument) + } + if len(req.DisplayName) > 63 { + return fmt.Errorf("%w: display_name must be at most 63 characters", ErrInvalidArgument) + } + if req.Spec.Url == "" { + return fmt.Errorf("%w: spec.url is required", ErrInvalidArgument) + } + u, err := url.Parse(req.Spec.Url) + if err != nil || u.Scheme == "" || u.Host == "" { + return fmt.Errorf("%w: spec.url must be a valid URL with scheme and host", ErrInvalidArgument) + } + switch u.Scheme { + case "https", "http", "ssh": + default: + return fmt.Errorf("%w: spec.url scheme must be https, http, or ssh", ErrInvalidArgument) + } + if u.Port() != "" { + return fmt.Errorf("%w: spec.url must not include a port", ErrInvalidArgument) + } + host := u.Hostname() + if host == "localhost" || strings.HasPrefix(host, "127.") || host == "::1" || host == "0.0.0.0" || host == "metadata.google.internal" || host == "169.254.169.254" { + return fmt.Errorf("%w: spec.url must not reference localhost or link-local addresses", ErrInvalidArgument) + } + if req.Spec.IntervalSeconds != nil { + v := int(*req.Spec.IntervalSeconds) + if v < minIntervalSeconds || v > maxIntervalSeconds { + return fmt.Errorf("%w: spec.interval_seconds must be between %d and %d", ErrInvalidArgument, minIntervalSeconds, maxIntervalSeconds) + } + } + if req.Spec.Path != nil { + p := filepath.Clean(*req.Spec.Path) + if filepath.IsAbs(p) { + return fmt.Errorf("%w: spec.path must be a relative path", ErrInvalidArgument) + } + if p == ".." || strings.HasPrefix(p, ".."+string(filepath.Separator)) { + return fmt.Errorf("%w: spec.path must not escape the repository root", ErrInvalidArgument) + } + } + return nil +} + +func getOrGenerateID(clientID *string) string { + if clientID != nil && *clientID != "" { + return *clientID + } + return uuid.New().String() +} + +func modelToAPI(m *model.GitRepository) v1alpha1.GitRepository { + path := fmt.Sprintf("git-repositories/%s", m.ID) + status := modelToStatus(m) + + repo := v1alpha1.GitRepository{ + Uid: &m.ID, + ApiVersion: m.ApiVersion, + DisplayName: m.DisplayName, + Spec: v1alpha1.GitRepositorySpec{ + Url: m.URL, + }, + Status: &status, + Path: &path, + CreateTime: &m.CreateTime, + UpdateTime: &m.UpdateTime, + } + + branch := m.Branch + repo.Spec.Ref = &struct { + Branch *string `json:"branch,omitempty"` + }{Branch: &branch} + + specPath := m.Path + repo.Spec.Path = &specPath + + interval := int32(m.IntervalSeconds) + repo.Spec.IntervalSeconds = &interval + + return repo +} + +func modelToStatus(m *model.GitRepository) v1alpha1.GitRepositoryStatus { + syncState := v1alpha1.GitRepositoryStatusSyncState(m.SyncState) + status := v1alpha1.GitRepositoryStatus{ + SyncState: &syncState, + LastSyncTime: m.LastSyncTime, + } + if m.LastSyncedCommit != "" { + status.LastSyncedCommit = &m.LastSyncedCommit + } + if m.StatusMessage != "" { + status.Message = &m.StatusMessage + } + return status +} + +func apiToModel(id string, req v1alpha1.GitRepository) model.GitRepository { + m := model.GitRepository{ + ID: id, + ApiVersion: req.ApiVersion, + DisplayName: req.DisplayName, + URL: req.Spec.Url, + Branch: "main", + Path: ".", + IntervalSeconds: 60, + SyncState: "PENDING", + } + + if req.Spec.Ref != nil && req.Spec.Ref.Branch != nil { + m.Branch = *req.Spec.Ref.Branch + } + if req.Spec.Path != nil { + m.Path = *req.Spec.Path + } + if req.Spec.IntervalSeconds != nil { + m.IntervalSeconds = int(*req.Spec.IntervalSeconds) + } + return m +} + +func mapStoreError(err error) error { + switch { + case errors.Is(err, store.ErrGitRepositoryNotFound): + return fmt.Errorf("%w: %s", ErrNotFound, err.Error()) + case errors.Is(err, store.ErrGitRepositoryIDTaken): + return fmt.Errorf("%w: ID already taken", ErrAlreadyExists) + case errors.Is(err, store.ErrDisplayNameTaken): + return fmt.Errorf("%w: display_name already taken", ErrAlreadyExists) + case errors.Is(err, store.ErrInvalidPageToken): + return fmt.Errorf("%w: %s", ErrInvalidArgument, err.Error()) + default: + return err + } +} diff --git a/internal/gitops/store/git_repository.go b/internal/gitops/store/git_repository.go new file mode 100644 index 0000000..8178efa --- /dev/null +++ b/internal/gitops/store/git_repository.go @@ -0,0 +1,200 @@ +package store + +import ( + "context" + "errors" + "strings" + "time" + + "github.com/dcm-project/control-plane/internal/gitops/store/model" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +var ( + ErrGitRepositoryNotFound = errors.New("git repository not found") + ErrGitRepositoryIDTaken = errors.New("git repository ID already taken") + ErrDisplayNameTaken = errors.New("display_name already taken") +) + +// GitRepositoryListOptions contains options for listing git repositories. +type GitRepositoryListOptions struct { + PageToken *string + PageSize int +} + +// GitRepositoryListResult contains the result of a List operation. +type GitRepositoryListResult struct { + GitRepositories model.GitRepositoryList + NextPageToken string +} + +type GitRepository interface { + List(ctx context.Context, opts *GitRepositoryListOptions) (*GitRepositoryListResult, error) + ListAll(ctx context.Context) (model.GitRepositoryList, error) + Get(ctx context.Context, id string) (*model.GitRepository, error) + Create(ctx context.Context, repo model.GitRepository) (*model.GitRepository, error) + Update(ctx context.Context, repo model.GitRepository) (*model.GitRepository, error) + Delete(ctx context.Context, id string) error + UpdateSyncStatus(ctx context.Context, id, syncState, statusMessage, lastSyncedCommit string) error +} + +type GitRepositoryStore struct { + db *gorm.DB +} + +var _ GitRepository = (*GitRepositoryStore)(nil) + +func NewGitRepository(db *gorm.DB) GitRepository { + return &GitRepositoryStore{db: db} +} + +func (s *GitRepositoryStore) List(ctx context.Context, opts *GitRepositoryListOptions) (*GitRepositoryListResult, error) { + var repos model.GitRepositoryList + query := s.db.WithContext(ctx) + + pageSize := 100 + if opts != nil && opts.PageSize > 0 { + pageSize = opts.PageSize + } + + offset := 0 + if opts != nil && opts.PageToken != nil && *opts.PageToken != "" { + var err error + offset, err = decodePageToken(*opts.PageToken) + if err != nil { + return nil, err + } + } + + query = query.Order("id ASC").Limit(pageSize + 1).Offset(offset) + + if err := query.Find(&repos).Error; err != nil { + return nil, err + } + + result := &GitRepositoryListResult{ + GitRepositories: repos, + } + + if len(repos) > pageSize { + result.GitRepositories = repos[:pageSize] + nextOffset := offset + pageSize + nextPageToken, err := encodePageToken(nextOffset) + if err != nil { + return nil, err + } + result.NextPageToken = nextPageToken + } + + return result, nil +} + +func (s *GitRepositoryStore) ListAll(ctx context.Context) (model.GitRepositoryList, error) { + var repos model.GitRepositoryList + if err := s.db.WithContext(ctx).Order("id ASC").Find(&repos).Error; err != nil { + return nil, err + } + if repos == nil { + repos = model.GitRepositoryList{} + } + return repos, nil +} + +func (s *GitRepositoryStore) Get(ctx context.Context, id string) (*model.GitRepository, error) { + var repo model.GitRepository + if err := s.db.WithContext(ctx).First(&repo, "id = ?", id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrGitRepositoryNotFound + } + return nil, err + } + return &repo, nil +} + +func (s *GitRepositoryStore) Create(ctx context.Context, repo model.GitRepository) (*model.GitRepository, error) { + if err := s.db.WithContext(ctx).Clauses(clause.Returning{}).Select("*").Create(&repo).Error; err != nil { + return nil, s.mapUniqueConstraintError(ctx, err, repo) + } + return &repo, nil +} + +func (s *GitRepositoryStore) Update(ctx context.Context, repo model.GitRepository) (*model.GitRepository, error) { + result := s.db.WithContext(ctx).Model(&repo). + Select("display_name", "url", "branch", "path", "interval_seconds"). + Clauses(clause.Returning{}). + Updates(&repo) + if result.Error != nil { + return nil, s.mapUniqueConstraintError(ctx, result.Error, repo) + } + if result.RowsAffected == 0 { + return nil, ErrGitRepositoryNotFound + } + return &repo, nil +} + +func (s *GitRepositoryStore) Delete(ctx context.Context, id string) error { + result := s.db.WithContext(ctx).Where("id = ?", id).Delete(&model.GitRepository{}) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return ErrGitRepositoryNotFound + } + return nil +} + +func (s *GitRepositoryStore) UpdateSyncStatus(ctx context.Context, id, syncState, statusMessage, lastSyncedCommit string) error { + now := time.Now().UTC() + updates := map[string]any{ + "sync_state": syncState, + "status_message": statusMessage, + "last_sync_time": &now, + } + if lastSyncedCommit != "" { + updates["last_synced_commit"] = lastSyncedCommit + } + result := s.db.WithContext(ctx).Model(&model.GitRepository{}). + Where("id = ?", id). + Updates(updates) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return ErrGitRepositoryNotFound + } + return nil +} + +func (s *GitRepositoryStore) mapUniqueConstraintError(ctx context.Context, err error, attempted model.GitRepository) error { + if err == nil { + return nil + } + if !errors.Is(err, gorm.ErrDuplicatedKey) { + if !strings.Contains(strings.ToLower(err.Error()), "unique") && + !strings.Contains(err.Error(), "duplicate key") { + return err + } + } + + checks := []struct { + sentinel error + query *gorm.DB + }{ + {ErrGitRepositoryIDTaken, s.db.WithContext(ctx).Where("id = ?", attempted.ID).Limit(1)}, + {ErrDisplayNameTaken, s.db.WithContext(ctx).Where("display_name = ?", attempted.DisplayName).Limit(1)}, + } + + for _, c := range checks { + var row model.GitRepository + dberr := c.query.First(&row).Error + if dberr == nil { + return c.sentinel + } + if !errors.Is(dberr, gorm.ErrRecordNotFound) { + return err + } + } + + return err +} diff --git a/internal/gitops/store/git_repository_test.go b/internal/gitops/store/git_repository_test.go new file mode 100644 index 0000000..c2e84d3 --- /dev/null +++ b/internal/gitops/store/git_repository_test.go @@ -0,0 +1,233 @@ +package store_test + +import ( + "context" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + + "github.com/dcm-project/control-plane/internal/gitops/store" + "github.com/dcm-project/control-plane/internal/gitops/store/model" +) + +var _ = Describe("GitRepositoryStore", func() { + var ( + db *gorm.DB + dataStore store.Store + ctx context.Context + ) + + BeforeEach(func() { + var err error + db, err = gorm.Open(sqlite.Open(":memory:"), &gorm.Config{TranslateError: true}) + Expect(err).NotTo(HaveOccurred()) + Expect(db.AutoMigrate(&model.GitRepository{}, &model.ManagedInstance{})).To(Succeed()) + dataStore = store.NewStore(db) + ctx = context.Background() + }) + + Describe("Create", func() { + It("creates a git repository", func() { + repo := model.GitRepository{ + ID: "test-repo", + ApiVersion: "v1alpha1", + DisplayName: "Test Repo", + URL: "https://example.com/repo.git", + Branch: "main", + Path: ".", + } + created, err := dataStore.GitRepository().Create(ctx, repo) + Expect(err).NotTo(HaveOccurred()) + Expect(created.ID).To(Equal("test-repo")) + Expect(created.DisplayName).To(Equal("Test Repo")) + Expect(created.SyncState).To(Equal("PENDING")) + }) + + It("returns error on duplicate ID", func() { + repo := model.GitRepository{ + ID: "dup-id", + ApiVersion: "v1alpha1", + DisplayName: "First", + URL: "https://example.com/repo.git", + } + _, err := dataStore.GitRepository().Create(ctx, repo) + Expect(err).NotTo(HaveOccurred()) + + repo2 := model.GitRepository{ + ID: "dup-id", + ApiVersion: "v1alpha1", + DisplayName: "Second", + URL: "https://example.com/repo2.git", + } + _, err = dataStore.GitRepository().Create(ctx, repo2) + Expect(err).To(MatchError(store.ErrGitRepositoryIDTaken)) + }) + + It("returns error on duplicate display_name", func() { + repo := model.GitRepository{ + ID: "repo-1", + ApiVersion: "v1alpha1", + DisplayName: "Same Name", + URL: "https://example.com/repo.git", + } + _, err := dataStore.GitRepository().Create(ctx, repo) + Expect(err).NotTo(HaveOccurred()) + + repo2 := model.GitRepository{ + ID: "repo-2", + ApiVersion: "v1alpha1", + DisplayName: "Same Name", + URL: "https://example.com/repo2.git", + } + _, err = dataStore.GitRepository().Create(ctx, repo2) + Expect(err).To(MatchError(store.ErrDisplayNameTaken)) + }) + }) + + Describe("Get", func() { + It("returns the repository", func() { + repo := model.GitRepository{ + ID: "get-test", + ApiVersion: "v1alpha1", + DisplayName: "Get Test", + URL: "https://example.com/repo.git", + } + _, err := dataStore.GitRepository().Create(ctx, repo) + Expect(err).NotTo(HaveOccurred()) + + got, err := dataStore.GitRepository().Get(ctx, "get-test") + Expect(err).NotTo(HaveOccurred()) + Expect(got.DisplayName).To(Equal("Get Test")) + }) + + It("returns not found for non-existent ID", func() { + _, err := dataStore.GitRepository().Get(ctx, "nonexistent") + Expect(err).To(MatchError(store.ErrGitRepositoryNotFound)) + }) + }) + + Describe("List", func() { + BeforeEach(func() { + for _, id := range []string{"aaa", "bbb", "ccc"} { + _, err := dataStore.GitRepository().Create(ctx, model.GitRepository{ + ID: id, + ApiVersion: "v1alpha1", + DisplayName: "Repo " + id, + URL: "https://example.com/" + id + ".git", + }) + Expect(err).NotTo(HaveOccurred()) + } + }) + + It("lists all repositories", func() { + result, err := dataStore.GitRepository().List(ctx, &store.GitRepositoryListOptions{}) + Expect(err).NotTo(HaveOccurred()) + Expect(result.GitRepositories).To(HaveLen(3)) + Expect(result.NextPageToken).To(BeEmpty()) + }) + + It("supports pagination", func() { + result, err := dataStore.GitRepository().List(ctx, &store.GitRepositoryListOptions{PageSize: 2}) + Expect(err).NotTo(HaveOccurred()) + Expect(result.GitRepositories).To(HaveLen(2)) + Expect(result.NextPageToken).NotTo(BeEmpty()) + + result2, err := dataStore.GitRepository().List(ctx, &store.GitRepositoryListOptions{ + PageToken: &result.NextPageToken, + PageSize: 2, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(result2.GitRepositories).To(HaveLen(1)) + Expect(result2.NextPageToken).To(BeEmpty()) + }) + }) + + Describe("Delete", func() { + It("deletes an existing repository", func() { + _, err := dataStore.GitRepository().Create(ctx, model.GitRepository{ + ID: "del-test", + ApiVersion: "v1alpha1", + DisplayName: "Del Test", + URL: "https://example.com/repo.git", + }) + Expect(err).NotTo(HaveOccurred()) + + err = dataStore.GitRepository().Delete(ctx, "del-test") + Expect(err).NotTo(HaveOccurred()) + + _, err = dataStore.GitRepository().Get(ctx, "del-test") + Expect(err).To(MatchError(store.ErrGitRepositoryNotFound)) + }) + + It("returns not found for non-existent ID", func() { + err := dataStore.GitRepository().Delete(ctx, "nonexistent") + Expect(err).To(MatchError(store.ErrGitRepositoryNotFound)) + }) + }) + + Describe("UpdateSyncStatus", func() { + It("updates sync status fields", func() { + _, err := dataStore.GitRepository().Create(ctx, model.GitRepository{ + ID: "sync-test", + ApiVersion: "v1alpha1", + DisplayName: "Sync Test", + URL: "https://example.com/repo.git", + }) + Expect(err).NotTo(HaveOccurred()) + + err = dataStore.GitRepository().UpdateSyncStatus(ctx, "sync-test", "SYNCED", "All good", "abc123") + Expect(err).NotTo(HaveOccurred()) + + got, err := dataStore.GitRepository().Get(ctx, "sync-test") + Expect(err).NotTo(HaveOccurred()) + Expect(got.SyncState).To(Equal("SYNCED")) + Expect(got.StatusMessage).To(Equal("All good")) + Expect(got.LastSyncedCommit).To(Equal("abc123")) + Expect(got.LastSyncTime).NotTo(BeNil()) + }) + + It("returns not found for non-existent ID", func() { + err := dataStore.GitRepository().UpdateSyncStatus(ctx, "nonexistent", "SYNCED", "", "") + Expect(err).To(MatchError(store.ErrGitRepositoryNotFound)) + }) + }) + + Describe("Update", func() { + It("updates mutable fields", func() { + _, err := dataStore.GitRepository().Create(ctx, model.GitRepository{ + ID: "upd-test", + ApiVersion: "v1alpha1", + DisplayName: "Original", + URL: "https://example.com/repo.git", + Branch: "main", + IntervalSeconds: 60, + }) + Expect(err).NotTo(HaveOccurred()) + + updated, err := dataStore.GitRepository().Update(ctx, model.GitRepository{ + ID: "upd-test", + DisplayName: "Updated", + URL: "https://example.com/new-repo.git", + Branch: "develop", + Path: "apps/", + IntervalSeconds: 30, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(updated.DisplayName).To(Equal("Updated")) + Expect(updated.URL).To(Equal("https://example.com/new-repo.git")) + Expect(updated.Branch).To(Equal("develop")) + Expect(updated.IntervalSeconds).To(Equal(30)) + }) + + It("returns not found for non-existent ID", func() { + _, err := dataStore.GitRepository().Update(ctx, model.GitRepository{ + ID: "nonexistent", + DisplayName: "Test", + URL: "https://example.com/repo.git", + }) + Expect(err).To(MatchError(store.ErrGitRepositoryNotFound)) + }) + }) +}) diff --git a/internal/gitops/store/managed_instance.go b/internal/gitops/store/managed_instance.go new file mode 100644 index 0000000..7d2468a --- /dev/null +++ b/internal/gitops/store/managed_instance.go @@ -0,0 +1,58 @@ +package store + +import ( + "context" + + "github.com/dcm-project/control-plane/internal/gitops/store/model" + "gorm.io/gorm" +) + +// ManagedInstance provides persistence for the repo->instance ownership mapping. +type ManagedInstance interface { + // ListByRepo returns all instance IDs managed by the given git repository. + ListByRepo(ctx context.Context, gitRepositoryID string) ([]string, error) + // Add records that the given instance is managed by the given git repository. + Add(ctx context.Context, gitRepositoryID, instanceID string) error + // Remove removes the ownership record for the given instance under the given repo. + Remove(ctx context.Context, gitRepositoryID, instanceID string) error +} + +type ManagedInstanceStore struct { + db *gorm.DB +} + +var _ ManagedInstance = (*ManagedInstanceStore)(nil) + +func NewManagedInstance(db *gorm.DB) ManagedInstance { + return &ManagedInstanceStore{db: db} +} + +func (s *ManagedInstanceStore) ListByRepo(ctx context.Context, gitRepositoryID string) ([]string, error) { + var ids []string + err := s.db.WithContext(ctx). + Model(&model.ManagedInstance{}). + Where("git_repository_id = ?", gitRepositoryID). + Order("instance_id ASC"). + Pluck("instance_id", &ids).Error + if err != nil { + return nil, err + } + if ids == nil { + ids = []string{} + } + return ids, nil +} + +func (s *ManagedInstanceStore) Add(ctx context.Context, gitRepositoryID, instanceID string) error { + record := model.ManagedInstance{ + GitRepositoryID: gitRepositoryID, + InstanceID: instanceID, + } + return s.db.WithContext(ctx).Create(&record).Error +} + +func (s *ManagedInstanceStore) Remove(ctx context.Context, gitRepositoryID, instanceID string) error { + return s.db.WithContext(ctx). + Where("git_repository_id = ? AND instance_id = ?", gitRepositoryID, instanceID). + Delete(&model.ManagedInstance{}).Error +} diff --git a/internal/gitops/store/model/git_repository.go b/internal/gitops/store/model/git_repository.go new file mode 100644 index 0000000..b64952d --- /dev/null +++ b/internal/gitops/store/model/git_repository.go @@ -0,0 +1,26 @@ +// Package model defines the GORM database models for gitops resources. +package model + +import ( + "time" +) + +// GitRepository represents a git repository configuration in the database. +type GitRepository struct { + ID string `gorm:"column:id;primaryKey;type:varchar(63)"` + ApiVersion string `gorm:"column:api_version;not null"` + DisplayName string `gorm:"column:display_name;not null;uniqueIndex"` + URL string `gorm:"column:url;not null"` + Branch string `gorm:"column:branch;not null;default:main"` + Path string `gorm:"column:path;not null;default:."` + IntervalSeconds int `gorm:"column:interval_seconds;not null;default:60"` + SyncState string `gorm:"column:sync_state;not null;default:PENDING"` + LastSyncedCommit string `gorm:"column:last_synced_commit"` + StatusMessage string `gorm:"column:status_message"` + LastSyncTime *time.Time `gorm:"column:last_sync_time"` + CreateTime time.Time `gorm:"column:create_time;autoCreateTime"` + UpdateTime time.Time `gorm:"column:update_time;autoUpdateTime"` +} + +// GitRepositoryList is a slice of GitRepository for list results. +type GitRepositoryList []GitRepository diff --git a/internal/gitops/store/model/managed_instance.go b/internal/gitops/store/model/managed_instance.go new file mode 100644 index 0000000..9bea16f --- /dev/null +++ b/internal/gitops/store/model/managed_instance.go @@ -0,0 +1,16 @@ +package model + +import "time" + +// ManagedInstance records that a CatalogItemInstance is managed by a GitRepository. +type ManagedInstance struct { + GitRepositoryID string `gorm:"column:git_repository_id;primaryKey;type:varchar(63)"` + InstanceID string `gorm:"column:instance_id;primaryKey;type:varchar(63)"` + CreateTime time.Time `gorm:"column:create_time;autoCreateTime"` + + GitRepositoryRef *GitRepository `gorm:"foreignKey:GitRepositoryID;references:ID;constraint:OnDelete:CASCADE"` +} + +func (ManagedInstance) TableName() string { + return "gitops_managed_instances" +} diff --git a/internal/gitops/store/pagination.go b/internal/gitops/store/pagination.go new file mode 100644 index 0000000..f5e6716 --- /dev/null +++ b/internal/gitops/store/pagination.go @@ -0,0 +1,42 @@ +package store + +import ( + "encoding/base64" + "encoding/json" + "errors" + "fmt" +) + +// ErrInvalidPageToken is returned when page_token cannot be decoded or validated. +var ErrInvalidPageToken = errors.New("store: invalid page_token") + +type pageTokenPayload struct { + Offset int `json:"offset"` +} + +func decodePageToken(token string) (int, error) { + decoded, err := base64.StdEncoding.DecodeString(token) + if err != nil { + return 0, fmt.Errorf("%w: %v", ErrInvalidPageToken, err) + } + + var payload pageTokenPayload + if err := json.Unmarshal(decoded, &payload); err != nil { + return 0, fmt.Errorf("%w: %v", ErrInvalidPageToken, err) + } + if payload.Offset < 0 { + return 0, fmt.Errorf("%w: negative offset", ErrInvalidPageToken) + } + return payload.Offset, nil +} + +func encodePageToken(offset int) (string, error) { + if offset < 0 { + return "", fmt.Errorf("%w: negative offset", ErrInvalidPageToken) + } + payload, err := json.Marshal(pageTokenPayload{Offset: offset}) + if err != nil { + return "", err + } + return base64.StdEncoding.EncodeToString(payload), nil +} diff --git a/internal/gitops/store/store.go b/internal/gitops/store/store.go new file mode 100644 index 0000000..405edf7 --- /dev/null +++ b/internal/gitops/store/store.go @@ -0,0 +1,40 @@ +// Package store provides the data persistence layer for gitops resources. +package store + +import "gorm.io/gorm" + +type Store interface { + Close() error + GitRepository() GitRepository + ManagedInstance() ManagedInstance +} + +type DataStore struct { + db *gorm.DB + gitRepository GitRepository + managedInstance ManagedInstance +} + +func NewStore(db *gorm.DB) Store { + return &DataStore{ + db: db, + gitRepository: NewGitRepository(db), + managedInstance: NewManagedInstance(db), + } +} + +func (s *DataStore) Close() error { + sqlDB, err := s.db.DB() + if err != nil { + return err + } + return sqlDB.Close() +} + +func (s *DataStore) GitRepository() GitRepository { + return s.gitRepository +} + +func (s *DataStore) ManagedInstance() ManagedInstance { + return s.managedInstance +} diff --git a/internal/gitops/store/suite_test.go b/internal/gitops/store/suite_test.go new file mode 100644 index 0000000..0879728 --- /dev/null +++ b/internal/gitops/store/suite_test.go @@ -0,0 +1,13 @@ +package store_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestStore(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "GitOps Store Suite") +} diff --git a/make/gitops.mk b/make/gitops.mk new file mode 100644 index 0000000..38be747 --- /dev/null +++ b/make/gitops.mk @@ -0,0 +1,48 @@ +# GitOps domain (codegen and subsystem tests). +GITOPS_DOMAIN := gitops +GITOPS_API := api/$(GITOPS_DOMAIN)/v1alpha1 +GITOPS_SERVER_DIR := internal/$(GITOPS_DOMAIN)/api/server +GITOPS_CLIENT_DIR := pkg/$(GITOPS_DOMAIN)/client + +generate-gitops-types: + go run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen \ + --config=$(GITOPS_API)/types.gen.cfg \ + -o $(GITOPS_API)/types.gen.go \ + $(GITOPS_API)/openapi.yaml + +generate-gitops-spec: + go run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen \ + --config=$(GITOPS_API)/spec.gen.cfg \ + -o $(GITOPS_API)/spec.gen.go \ + $(GITOPS_API)/openapi.yaml + +generate-gitops-server: + go run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen \ + --config=$(GITOPS_SERVER_DIR)/server.gen.cfg \ + -o $(GITOPS_SERVER_DIR)/server.gen.go \ + $(GITOPS_API)/openapi.yaml + +generate-gitops-client: + go run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen \ + --config=$(GITOPS_CLIENT_DIR)/client.gen.cfg \ + -o $(GITOPS_CLIENT_DIR)/client.gen.go \ + $(GITOPS_API)/openapi.yaml + +generate-gitops-api: generate-gitops-types generate-gitops-spec generate-gitops-server generate-gitops-client + +test-gitops: + $(GINKGO) $(GINKGO_FLAGS) ./internal/$(GITOPS_DOMAIN) ./pkg/$(GITOPS_DOMAIN) + +gitops-subsystem-test-up: + $(COMPOSE) -f test/subsystem/$(GITOPS_DOMAIN)/docker-compose.yaml up -d --build + +gitops-subsystem-test-down: + $(COMPOSE) -f test/subsystem/$(GITOPS_DOMAIN)/docker-compose.yaml down -v + +gitops-subsystem-test: + $(GINKGO) $(GINKGO_FLAGS) -tags=subsystem ./test/subsystem/$(GITOPS_DOMAIN) + +.PHONY: generate-gitops-types generate-gitops-spec \ + generate-gitops-server generate-gitops-client generate-gitops-api \ + test-gitops gitops-subsystem-test-up gitops-subsystem-test-down \ + gitops-subsystem-test diff --git a/pkg/gitops/client/client.gen.cfg b/pkg/gitops/client/client.gen.cfg new file mode 100644 index 0000000..75b0c9f --- /dev/null +++ b/pkg/gitops/client/client.gen.cfg @@ -0,0 +1,9 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/oapi-codegen/oapi-codegen/HEAD/configuration-schema.json +package: client +generate: + client: true +additional-imports: + - alias: . + package: github.com/dcm-project/control-plane/api/gitops/v1alpha1 +output-options: + skip-prune: true diff --git a/pkg/gitops/client/client.gen.go b/pkg/gitops/client/client.gen.go new file mode 100644 index 0000000..6acb817 --- /dev/null +++ b/pkg/gitops/client/client.gen.go @@ -0,0 +1,1016 @@ +// Package client provides primitives to interact with the openapi HTTP API. +// +// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.7.0 DO NOT EDIT. +package client + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + + . "github.com/dcm-project/control-plane/api/gitops/v1alpha1" + "github.com/oapi-codegen/runtime" +) + +// RequestEditorFn is the function signature for the RequestEditor callback function +type RequestEditorFn func(ctx context.Context, req *http.Request) error + +// Doer performs HTTP requests. +// +// The standard http.Client implements this interface. +type HttpRequestDoer interface { + Do(req *http.Request) (*http.Response, error) +} + +// Client which conforms to the OpenAPI3 specification for this service. +type Client struct { + // The endpoint of the server conforming to this interface, with scheme, + // https://api.deepmap.com for example. This can contain a path relative + // to the server, such as https://api.deepmap.com/dev-test, and all the + // paths in the swagger spec will be appended to the server. + Server string + + // Doer for performing requests, typically a *http.Client with any + // customized settings, such as certificate chains. + Client HttpRequestDoer + + // A list of callbacks for modifying requests which are generated before sending over + // the network. + RequestEditors []RequestEditorFn +} + +// ClientOption allows setting custom parameters during construction +type ClientOption func(*Client) error + +// Creates a new Client, with reasonable defaults +func NewClient(server string, opts ...ClientOption) (*Client, error) { + // create a client with sane default values + client := Client{ + Server: server, + } + // mutate client and add all optional params + for _, o := range opts { + if err := o(&client); err != nil { + return nil, err + } + } + // ensure the server URL always has a trailing slash + if !strings.HasSuffix(client.Server, "/") { + client.Server += "/" + } + // create httpClient, if not already present + if client.Client == nil { + client.Client = &http.Client{} + } + return &client, nil +} + +// WithHTTPClient allows overriding the default Doer, which is +// automatically created using http.Client. This is useful for tests. +func WithHTTPClient(doer HttpRequestDoer) ClientOption { + return func(c *Client) error { + c.Client = doer + return nil + } +} + +// WithRequestEditorFn allows setting up a callback function, which will be +// called right before sending the request. This can be used to mutate the request. +func WithRequestEditorFn(fn RequestEditorFn) ClientOption { + return func(c *Client) error { + c.RequestEditors = append(c.RequestEditors, fn) + return nil + } +} + +// The interface specification for the client above. +type ClientInterface interface { + // ListGitRepositories request + ListGitRepositories(ctx context.Context, params *ListGitRepositoriesParams, reqEditors ...RequestEditorFn) (*http.Response, error) + + // CreateGitRepositoryWithBody request with any body + CreateGitRepositoryWithBody(ctx context.Context, params *CreateGitRepositoryParams, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + CreateGitRepository(ctx context.Context, params *CreateGitRepositoryParams, body CreateGitRepositoryJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + + // DeleteGitRepository request + DeleteGitRepository(ctx context.Context, gitRepositoryId GitRepositoryIdPath, reqEditors ...RequestEditorFn) (*http.Response, error) + + // GetGitRepository request + GetGitRepository(ctx context.Context, gitRepositoryId GitRepositoryIdPath, reqEditors ...RequestEditorFn) (*http.Response, error) + + // UpdateGitRepositoryWithBody request with any body + UpdateGitRepositoryWithBody(ctx context.Context, gitRepositoryId GitRepositoryIdPath, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + UpdateGitRepository(ctx context.Context, gitRepositoryId GitRepositoryIdPath, body UpdateGitRepositoryJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) +} + +func (c *Client) ListGitRepositories(ctx context.Context, params *ListGitRepositoriesParams, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewListGitRepositoriesRequest(c.Server, params) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) CreateGitRepositoryWithBody(ctx context.Context, params *CreateGitRepositoryParams, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewCreateGitRepositoryRequestWithBody(c.Server, params, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) CreateGitRepository(ctx context.Context, params *CreateGitRepositoryParams, body CreateGitRepositoryJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewCreateGitRepositoryRequest(c.Server, params, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) DeleteGitRepository(ctx context.Context, gitRepositoryId GitRepositoryIdPath, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewDeleteGitRepositoryRequest(c.Server, gitRepositoryId) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) GetGitRepository(ctx context.Context, gitRepositoryId GitRepositoryIdPath, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewGetGitRepositoryRequest(c.Server, gitRepositoryId) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) UpdateGitRepositoryWithBody(ctx context.Context, gitRepositoryId GitRepositoryIdPath, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewUpdateGitRepositoryRequestWithBody(c.Server, gitRepositoryId, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) UpdateGitRepository(ctx context.Context, gitRepositoryId GitRepositoryIdPath, body UpdateGitRepositoryJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewUpdateGitRepositoryRequest(c.Server, gitRepositoryId, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +// NewListGitRepositoriesRequest generates requests for ListGitRepositories +func NewListGitRepositoriesRequest(server string, params *ListGitRepositoriesParams) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/git-repositories") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + if params != nil { + // queryValues collects non-styled parameters (passthrough, JSON) + // that are safe to round-trip through url.Values.Encode(). + queryValues := queryURL.Query() + // rawQueryFragments collects pre-encoded query fragments from + // styled parameters, preserving literal commas as delimiters + // per the OpenAPI spec (e.g. "color=blue,black,brown"). + var rawQueryFragments []string + + if params.PageToken != nil { + + if queryFrag, err := runtime.StyleParamWithOptions("form", true, "page_token", *params.PageToken, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { + return nil, err + } else { + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) + } + } + + } + + if params.MaxPageSize != nil { + + if queryFrag, err := runtime.StyleParamWithOptions("form", true, "max_page_size", *params.MaxPageSize, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "integer", Format: "int32"}); err != nil { + return nil, err + } else { + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) + } + } + + } + + if encoded := queryValues.Encode(); encoded != "" { + rawQueryFragments = append(rawQueryFragments, encoded) + } + queryURL.RawQuery = strings.Join(rawQueryFragments, "&") + } + + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewCreateGitRepositoryRequest calls the generic CreateGitRepository builder with application/json body +func NewCreateGitRepositoryRequest(server string, params *CreateGitRepositoryParams, body CreateGitRepositoryJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewCreateGitRepositoryRequestWithBody(server, params, "application/json", bodyReader) +} + +// NewCreateGitRepositoryRequestWithBody generates requests for CreateGitRepository with any type of body +func NewCreateGitRepositoryRequestWithBody(server string, params *CreateGitRepositoryParams, contentType string, body io.Reader) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/git-repositories") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + if params != nil { + // queryValues collects non-styled parameters (passthrough, JSON) + // that are safe to round-trip through url.Values.Encode(). + queryValues := queryURL.Query() + // rawQueryFragments collects pre-encoded query fragments from + // styled parameters, preserving literal commas as delimiters + // per the OpenAPI spec (e.g. "color=blue,black,brown"). + var rawQueryFragments []string + + if params.Id != nil { + + if queryFrag, err := runtime.StyleParamWithOptions("form", true, "id", *params.Id, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { + return nil, err + } else { + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) + } + } + + } + + if encoded := queryValues.Encode(); encoded != "" { + rawQueryFragments = append(rawQueryFragments, encoded) + } + queryURL.RawQuery = strings.Join(rawQueryFragments, "&") + } + + req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + +// NewDeleteGitRepositoryRequest generates requests for DeleteGitRepository +func NewDeleteGitRepositoryRequest(server string, gitRepositoryId GitRepositoryIdPath) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "gitRepositoryId", gitRepositoryId, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/git-repositories/%s", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodDelete, queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewGetGitRepositoryRequest generates requests for GetGitRepository +func NewGetGitRepositoryRequest(server string, gitRepositoryId GitRepositoryIdPath) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "gitRepositoryId", gitRepositoryId, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/git-repositories/%s", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewUpdateGitRepositoryRequest calls the generic UpdateGitRepository builder with application/json body +func NewUpdateGitRepositoryRequest(server string, gitRepositoryId GitRepositoryIdPath, body UpdateGitRepositoryJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewUpdateGitRepositoryRequestWithBody(server, gitRepositoryId, "application/json", bodyReader) +} + +// NewUpdateGitRepositoryRequestWithBody generates requests for UpdateGitRepository with any type of body +func NewUpdateGitRepositoryRequestWithBody(server string, gitRepositoryId GitRepositoryIdPath, contentType string, body io.Reader) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "gitRepositoryId", gitRepositoryId, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/git-repositories/%s", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPut, queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + +func (c *Client) applyEditors(ctx context.Context, req *http.Request, additionalEditors []RequestEditorFn) error { + for _, r := range c.RequestEditors { + if err := r(ctx, req); err != nil { + return err + } + } + for _, r := range additionalEditors { + if err := r(ctx, req); err != nil { + return err + } + } + return nil +} + +// ClientWithResponses builds on ClientInterface to offer response payloads +type ClientWithResponses struct { + ClientInterface +} + +// NewClientWithResponses creates a new ClientWithResponses, which wraps +// Client with return type handling +func NewClientWithResponses(server string, opts ...ClientOption) (*ClientWithResponses, error) { + client, err := NewClient(server, opts...) + if err != nil { + return nil, err + } + return &ClientWithResponses{client}, nil +} + +// WithBaseURL overrides the baseURL. +func WithBaseURL(baseURL string) ClientOption { + return func(c *Client) error { + newBaseURL, err := url.Parse(baseURL) + if err != nil { + return err + } + c.Server = newBaseURL.String() + return nil + } +} + +// ClientWithResponsesInterface is the interface specification for the client with responses above. +type ClientWithResponsesInterface interface { + // ListGitRepositoriesWithResponse request + ListGitRepositoriesWithResponse(ctx context.Context, params *ListGitRepositoriesParams, reqEditors ...RequestEditorFn) (*ListGitRepositoriesResponse, error) + + // CreateGitRepositoryWithBodyWithResponse request with any body + CreateGitRepositoryWithBodyWithResponse(ctx context.Context, params *CreateGitRepositoryParams, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*CreateGitRepositoryResponse, error) + + CreateGitRepositoryWithResponse(ctx context.Context, params *CreateGitRepositoryParams, body CreateGitRepositoryJSONRequestBody, reqEditors ...RequestEditorFn) (*CreateGitRepositoryResponse, error) + + // DeleteGitRepositoryWithResponse request + DeleteGitRepositoryWithResponse(ctx context.Context, gitRepositoryId GitRepositoryIdPath, reqEditors ...RequestEditorFn) (*DeleteGitRepositoryResponse, error) + + // GetGitRepositoryWithResponse request + GetGitRepositoryWithResponse(ctx context.Context, gitRepositoryId GitRepositoryIdPath, reqEditors ...RequestEditorFn) (*GetGitRepositoryResponse, error) + + // UpdateGitRepositoryWithBodyWithResponse request with any body + UpdateGitRepositoryWithBodyWithResponse(ctx context.Context, gitRepositoryId GitRepositoryIdPath, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*UpdateGitRepositoryResponse, error) + + UpdateGitRepositoryWithResponse(ctx context.Context, gitRepositoryId GitRepositoryIdPath, body UpdateGitRepositoryJSONRequestBody, reqEditors ...RequestEditorFn) (*UpdateGitRepositoryResponse, error) +} + +type ListGitRepositoriesResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *GitRepositoryList + JSON400 *BadRequest + JSON401 *Unauthorized + JSON403 *Forbidden + JSON500 *InternalServerError +} + +// Status returns HTTPResponse.Status +func (r ListGitRepositoriesResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r ListGitRepositoriesResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r ListGitRepositoriesResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +type CreateGitRepositoryResponse struct { + Body []byte + HTTPResponse *http.Response + JSON201 *GitRepository + JSON400 *Error + JSON401 *Unauthorized + JSON403 *Forbidden + JSON409 *AlreadyExists + JSON500 *InternalServerError +} + +// Status returns HTTPResponse.Status +func (r CreateGitRepositoryResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r CreateGitRepositoryResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r CreateGitRepositoryResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +type DeleteGitRepositoryResponse struct { + Body []byte + HTTPResponse *http.Response + JSON401 *Unauthorized + JSON403 *Forbidden + JSON404 *NotFound + JSON500 *InternalServerError +} + +// Status returns HTTPResponse.Status +func (r DeleteGitRepositoryResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r DeleteGitRepositoryResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r DeleteGitRepositoryResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +type GetGitRepositoryResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *GitRepository + JSON401 *Unauthorized + JSON403 *Forbidden + JSON404 *NotFound + JSON500 *InternalServerError +} + +// Status returns HTTPResponse.Status +func (r GetGitRepositoryResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r GetGitRepositoryResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r GetGitRepositoryResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +type UpdateGitRepositoryResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *GitRepository + JSON400 *Error + JSON401 *Unauthorized + JSON403 *Forbidden + JSON404 *NotFound + JSON500 *InternalServerError +} + +// Status returns HTTPResponse.Status +func (r UpdateGitRepositoryResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r UpdateGitRepositoryResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r UpdateGitRepositoryResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +// ListGitRepositoriesWithResponse request returning *ListGitRepositoriesResponse +func (c *ClientWithResponses) ListGitRepositoriesWithResponse(ctx context.Context, params *ListGitRepositoriesParams, reqEditors ...RequestEditorFn) (*ListGitRepositoriesResponse, error) { + rsp, err := c.ListGitRepositories(ctx, params, reqEditors...) + if err != nil { + return nil, err + } + return ParseListGitRepositoriesResponse(rsp) +} + +// CreateGitRepositoryWithBodyWithResponse request with arbitrary body returning *CreateGitRepositoryResponse +func (c *ClientWithResponses) CreateGitRepositoryWithBodyWithResponse(ctx context.Context, params *CreateGitRepositoryParams, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*CreateGitRepositoryResponse, error) { + rsp, err := c.CreateGitRepositoryWithBody(ctx, params, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseCreateGitRepositoryResponse(rsp) +} + +func (c *ClientWithResponses) CreateGitRepositoryWithResponse(ctx context.Context, params *CreateGitRepositoryParams, body CreateGitRepositoryJSONRequestBody, reqEditors ...RequestEditorFn) (*CreateGitRepositoryResponse, error) { + rsp, err := c.CreateGitRepository(ctx, params, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseCreateGitRepositoryResponse(rsp) +} + +// DeleteGitRepositoryWithResponse request returning *DeleteGitRepositoryResponse +func (c *ClientWithResponses) DeleteGitRepositoryWithResponse(ctx context.Context, gitRepositoryId GitRepositoryIdPath, reqEditors ...RequestEditorFn) (*DeleteGitRepositoryResponse, error) { + rsp, err := c.DeleteGitRepository(ctx, gitRepositoryId, reqEditors...) + if err != nil { + return nil, err + } + return ParseDeleteGitRepositoryResponse(rsp) +} + +// GetGitRepositoryWithResponse request returning *GetGitRepositoryResponse +func (c *ClientWithResponses) GetGitRepositoryWithResponse(ctx context.Context, gitRepositoryId GitRepositoryIdPath, reqEditors ...RequestEditorFn) (*GetGitRepositoryResponse, error) { + rsp, err := c.GetGitRepository(ctx, gitRepositoryId, reqEditors...) + if err != nil { + return nil, err + } + return ParseGetGitRepositoryResponse(rsp) +} + +// UpdateGitRepositoryWithBodyWithResponse request with arbitrary body returning *UpdateGitRepositoryResponse +func (c *ClientWithResponses) UpdateGitRepositoryWithBodyWithResponse(ctx context.Context, gitRepositoryId GitRepositoryIdPath, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*UpdateGitRepositoryResponse, error) { + rsp, err := c.UpdateGitRepositoryWithBody(ctx, gitRepositoryId, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseUpdateGitRepositoryResponse(rsp) +} + +func (c *ClientWithResponses) UpdateGitRepositoryWithResponse(ctx context.Context, gitRepositoryId GitRepositoryIdPath, body UpdateGitRepositoryJSONRequestBody, reqEditors ...RequestEditorFn) (*UpdateGitRepositoryResponse, error) { + rsp, err := c.UpdateGitRepository(ctx, gitRepositoryId, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseUpdateGitRepositoryResponse(rsp) +} + +// ParseListGitRepositoriesResponse parses an HTTP response from a ListGitRepositoriesWithResponse call +func ParseListGitRepositoriesResponse(rsp *http.Response) (*ListGitRepositoriesResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &ListGitRepositoriesResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest GitRepositoryList + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest BadRequest + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest Unauthorized + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest Forbidden + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest InternalServerError + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + + } + + return response, nil +} + +// ParseCreateGitRepositoryResponse parses an HTTP response from a CreateGitRepositoryWithResponse call +func ParseCreateGitRepositoryResponse(rsp *http.Response) (*CreateGitRepositoryResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &CreateGitRepositoryResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 201: + var dest GitRepository + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON201 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest Error + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest Unauthorized + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest Forbidden + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 409: + var dest AlreadyExists + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON409 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest InternalServerError + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + + } + + return response, nil +} + +// ParseDeleteGitRepositoryResponse parses an HTTP response from a DeleteGitRepositoryWithResponse call +func ParseDeleteGitRepositoryResponse(rsp *http.Response) (*DeleteGitRepositoryResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &DeleteGitRepositoryResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest Unauthorized + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest Forbidden + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest NotFound + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest InternalServerError + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + + } + + return response, nil +} + +// ParseGetGitRepositoryResponse parses an HTTP response from a GetGitRepositoryWithResponse call +func ParseGetGitRepositoryResponse(rsp *http.Response) (*GetGitRepositoryResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &GetGitRepositoryResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest GitRepository + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest Unauthorized + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest Forbidden + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest NotFound + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest InternalServerError + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + + } + + return response, nil +} + +// ParseUpdateGitRepositoryResponse parses an HTTP response from a UpdateGitRepositoryWithResponse call +func ParseUpdateGitRepositoryResponse(rsp *http.Response) (*UpdateGitRepositoryResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &UpdateGitRepositoryResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest GitRepository + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest Error + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest Unauthorized + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest Forbidden + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest NotFound + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest InternalServerError + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + + } + + return response, nil +} +