From 206881ec0d4106b9e0f070b35392779fd084f4a0 Mon Sep 17 00:00:00 2001 From: Munish Chouhan Date: Wed, 26 Feb 2025 19:04:52 +0100 Subject: [PATCH 1/9] Wv 102 merge https docs.seqera.io wave api into openapi (#800) * added route doc Signed-off-by: munishchouhan * added examples Signed-off-by: munishchouhan * added field docs Signed-off-by: munishchouhan --------- Signed-off-by: munishchouhan --- typespec/models/BuildStatusResponse.tsp | 8 ++++ typespec/models/CondaOpts.tsp | 10 +++- typespec/models/CondaPackages.tsp | 14 ++++++ typespec/models/ContainerConfig.tsp | 19 ++++++++ typespec/models/ContainerInspectConfig.tsp | 22 ++++++++- typespec/models/ContainerInspectRequest.tsp | 12 ++++- typespec/models/ContainerInspectResponse.tsp | 48 ++++++++++++++++++- typespec/models/ContainerLayer.tsp | 12 +++++ typespec/models/ContainerMirrorResponse.tsp | 16 +++++++ typespec/models/ContainerPlatform.tsp | 5 ++ typespec/models/ContainerRequest.tsp | 47 ++++++++++++++---- typespec/models/ContainerResponse.tsp | 24 ++++++++++ typespec/models/ContainerStatusResponse.tsp | 16 +++++-- typespec/models/Manifest.tsp | 18 ++++++- typespec/models/ManifestLayer.tsp | 7 ++- typespec/models/Packages.tsp | 10 ---- typespec/models/RootFS.tsp | 7 +++ typespec/models/User.tsp | 5 ++ .../models/ValidateRegistryCredsRequest.tsp | 6 ++- typespec/models/Vulnerability.tsp | 11 ++++- typespec/models/WaveBuildRecord.tsp | 1 + typespec/routes.tsp | 12 ++++- 22 files changed, 296 insertions(+), 34 deletions(-) create mode 100644 typespec/models/CondaPackages.tsp delete mode 100644 typespec/models/Packages.tsp diff --git a/typespec/models/BuildStatusResponse.tsp b/typespec/models/BuildStatusResponse.tsp index d54133e1b..35b203e47 100644 --- a/typespec/models/BuildStatusResponse.tsp +++ b/typespec/models/BuildStatusResponse.tsp @@ -1,6 +1,14 @@ import "./Status.tsp"; @doc("Response payload for build status.") +@example(#{ + id:"6c084f2e43f86a78_1", + status:Status.COMPLETED, + startTime:"2024-04-09T20:31:35.355423Z", + duration: "123.914989000", + succeeded: true +} +) model BuildStatusResponse { duration: string; id: string; diff --git a/typespec/models/CondaOpts.tsp b/typespec/models/CondaOpts.tsp index f1c94356a..3d2165512 100644 --- a/typespec/models/CondaOpts.tsp +++ b/typespec/models/CondaOpts.tsp @@ -1,6 +1,14 @@ @doc("Options for Conda environments.") +@example(#{ + basePackages: "python=3.8", + commands: #["pip install bwa", "pip install salmon"], + mambaImage: "mambaorg/micromamba:0.15.3" +}) model CondaOpts { + @doc("Names of base packages.") basePackages: string; + @doc("Command to be included in the container.") commands: string[]; + @doc("Name of the docker image used to build Conda containers.") mambaImage: string; -} \ No newline at end of file +} diff --git a/typespec/models/CondaPackages.tsp b/typespec/models/CondaPackages.tsp new file mode 100644 index 000000000..09f589449 --- /dev/null +++ b/typespec/models/CondaPackages.tsp @@ -0,0 +1,14 @@ +import "./CondaOpts.tsp"; + +@doc("Package configurations for container builds.") +model CondaPackages { + @doc("Conda channels to search for packages.") + channels: string[]; + condaOpts?: CondaOpts; + @doc("Conda packages to install.") + entries: string[]; + @doc("The package environment file encoded as a base64 string.") + environment?: string; + @doc("This represents the type of package builder. Use `CONDA`.") + type: "CONDA"; +} diff --git a/typespec/models/ContainerConfig.tsp b/typespec/models/ContainerConfig.tsp index 834297775..ea46e0744 100644 --- a/typespec/models/ContainerConfig.tsp +++ b/typespec/models/ContainerConfig.tsp @@ -1,10 +1,29 @@ import "./ContainerLayer.tsp"; @doc("Configuration details for a container.") +@example(#{ + cmd: #["echo", "hello"], + entrypoint: #["/bin/sh"], + env: #["FOO=bar"], + layers: #[ + #{ + gzipDigest: "sha256:1234567890abcdef", + gzipSize: "1234", + location: "https://seqera.io/layer.tar.gz", + skipHashing: false, + tarDigest: "sha256:abcdef1234567890" + } + ], + workingDir: "/app" +}) model ContainerConfig { + @doc("The launch command to be used by the Wave container, e.g., `['echo', 'Hello world']` (optional).") cmd: string[]; + @doc("The container entrypoint command, e.g., `['/bin/bash']`.") entrypoint: string[]; + @doc("The environment variables to be defined in the Wave container, e.g., `['FOO=one','BAR=two']` (optional).") env: string[]; layers: ContainerLayer[]; + @doc("The work directory to be used in the Wave container, e.g., `/some/work/dir` (optional).") workingDir: string; } diff --git a/typespec/models/ContainerInspectConfig.tsp b/typespec/models/ContainerInspectConfig.tsp index bf59c40e9..49d64d762 100644 --- a/typespec/models/ContainerInspectConfig.tsp +++ b/typespec/models/ContainerInspectConfig.tsp @@ -1,7 +1,25 @@ import "./RootFS.tsp"; @doc("Configuration details of a container.") -model Config { +@example(#{ + architecture: "linux/amd64", + config: #{ + attachStdin: false, + attachStdout: true, + attachStderr: true, + tty: false, + env: #["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"], + cmd: #["sh"], + image: "alpine:latest" + }, + container: "docker.io/alpine:latest", + created: "2021-06-10T15:00:00.000000000Z", + rootfs: #{ + diff_ids: #["sha256:1234567890abcdef"], + type: "layers" + } +}) +model ContainerInspectConfig { architecture: string; config: { attachStdin: boolean; @@ -15,4 +33,4 @@ model Config { container: string; created: string; rootfs: RootFS; -} \ No newline at end of file +} diff --git a/typespec/models/ContainerInspectRequest.tsp b/typespec/models/ContainerInspectRequest.tsp index 00e2dd23e..125d57b15 100644 --- a/typespec/models/ContainerInspectRequest.tsp +++ b/typespec/models/ContainerInspectRequest.tsp @@ -1,7 +1,17 @@ @doc("Request payload for inspecting a container.") +@example(#{ + containerImage: "docker.io/alpine:latest", + towerAccessToken: "1234567890abcdef", + towerEndpoint: "https://api.cloud.seqera.io", + towerWorkspaceId: 1234567890 +}) model ContainerInspectRequest { + @doc("Name of the container to be inpected, e.g., `docker.io/library/ubuntu:latest`") containerImage: string; + @doc("Access token of the user account granting the access to the Seqera Platform service specified via `towerEndpoint` (optional). ") towerAccessToken: string; + @doc("Seqera Platform service endpoint from where container registry credentials are retrieved (optional). Default `https://api.cloud.seqera.io`. ") towerEndpoint: string; + @doc("ID of the Seqera Platform workspace from where the container registry credentials are retrieved (optional). When omitted the personal workspace is used.") towerWorkspaceId: int64; -} \ No newline at end of file +} diff --git a/typespec/models/ContainerInspectResponse.tsp b/typespec/models/ContainerInspectResponse.tsp index 6aee1cb04..6ac0c3b26 100644 --- a/typespec/models/ContainerInspectResponse.tsp +++ b/typespec/models/ContainerInspectResponse.tsp @@ -2,6 +2,52 @@ import "./ContainerInspectConfig.tsp"; import "./Manifest.tsp"; @doc("Response payload for inspecting a container.") +@example(#{ + Container: #{ + registry: "docker.io", + hostName: "docker.io", + imageName: "alpine", + reference: "latest", + digest: "sha256:1234567890abcdef", + config: #{ + architecture: "linux/amd64", + config: #{ + attachStdin: false, + attachStdout: true, + attachStderr: true, + tty: false, + env: #["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"], + cmd: #["sh"], + image: "alpine:latest" + }, + container: "docker.io/alpine:latest", + created: "2021-06-10T15:00:00.000000000Z", + rootfs: #{ + diff_ids: #["sha256:1234567890abcdef"], + type: "layers" + } + }, + manifest: #{ + schemaVersion: 2, + mediaType: "application/vnd.docker.distribution.manifest.v2+json", + config: #{ + mediaType: "application/vnd.docker.container.image.v1+json", + size: 123456, + digest: "sha256:1234567890abcdef" + }, + layers: #[ + #{ + mediaType: "application/vnd.docker.image.rootfs.diff.tar.gzip", + size: 123456, + digest: "sha256:1234567890abcdef" + } + ] + }, + v1: false, + v2: true, + oci: false + } +}) model ContainerInspectResponse { Container: { registry: string; @@ -9,7 +55,7 @@ model ContainerInspectResponse { imageName: string; reference: string; digest: string; - config: Config; + config: ContainerInspectConfig; manifest: Manifest; v1: boolean; v2: boolean; diff --git a/typespec/models/ContainerLayer.tsp b/typespec/models/ContainerLayer.tsp index 873cb4c84..5d3f1354b 100644 --- a/typespec/models/ContainerLayer.tsp +++ b/typespec/models/ContainerLayer.tsp @@ -1,8 +1,20 @@ @doc("Represents a layer in a container image.") +@example(#{ + gzipDigest: "sha256:1234567890abcdef", + gzipSize: "123456", + location: "https://example.com/image.tar.gz", + skipHashing: false, + tarDigest: "sha256:abcdef1234567890" +}) model ContainerLayer { + @doc("The SHA256 checksum of the provided layer tar gzip file, e.g., `sha256:a7c724b02...`.") gzipDigest: string; + @doc("The size in bytes of the the provided layer tar gzip file.") gzipSize: string; + @doc("Specifies a container image layer stored as a tar.gz file (optional). Either a HTTP URL to the file or a base64 encoded string prefixed with `data:`.") location: string; + @doc("If true, the layer tar file will not be hashed.") skipHashing: boolean; + @doc("The SHA256checksum of the provided tar file, e.g., `sha256:a7c724b02...`.") tarDigest: string; } diff --git a/typespec/models/ContainerMirrorResponse.tsp b/typespec/models/ContainerMirrorResponse.tsp index 419575b9d..06cb95221 100644 --- a/typespec/models/ContainerMirrorResponse.tsp +++ b/typespec/models/ContainerMirrorResponse.tsp @@ -2,6 +2,22 @@ import "./ContainerPlatform.tsp"; import "./Status.tsp"; @doc("Response payload for container mirroring.") +@example(#{ + mirrorId: "6c084f2e43f86a78_1", + digest: "sha256:1234567890abcdef", + sourceImage: "docker.io/alpine:latest", + targetImage: "docker.io/alpine:latest", + platform: #{ + os: "LINUX", + arch: "AMD64", + variant: "v1" + }, + creationTime: "2024-04-09T20:31:35.355423Z", + status: Status.COMPLETED, + duration: "123.914989000", + exitCode: 0, + logs: "Successfully mirrored image." +}) model ContainerMirrorResponse { mirrorId: string; digest: string; diff --git a/typespec/models/ContainerPlatform.tsp b/typespec/models/ContainerPlatform.tsp index 67da1a2f6..6e1e6ce77 100644 --- a/typespec/models/ContainerPlatform.tsp +++ b/typespec/models/ContainerPlatform.tsp @@ -1,4 +1,9 @@ @doc("Represents os platform of a container.") +@example(#{ + os: "linux", + arch: "amd64", + variant: "v1" +}) model ContainerPlatform { os: string; arch: string; diff --git a/typespec/models/ContainerRequest.tsp b/typespec/models/ContainerRequest.tsp index 38c4ef56a..c4973d2f5 100644 --- a/typespec/models/ContainerRequest.tsp +++ b/typespec/models/ContainerRequest.tsp @@ -1,31 +1,58 @@ import "./ContainerConfig.tsp"; -import "./Packages.tsp"; +import "./CondaPackages.tsp"; import "./ScanMode.tsp"; import "./ScanLevel.tsp"; @doc("Request payload for creating a container token.") +@example(#{ + packages:#{ + type: "CONDA", + entries: #["salmon", "bwa"], + channels: #["conda-forge", "bioconda"] + }, + format: "docker", + containerPlatform:"linux/amd64" +}) model ContainerRequest { - buildContext: ContainerLayer; + buildContext?: ContainerLayer; + @doc("Container repository where container builds should be pushed, e.g., `docker.io/user/my-image` (optional).") buildRepository?: string; + @doc("Container repository used to cache build layers `docker.io/user/my-cache` (optional).") cacheRepository?: string; - containerConfig: ContainerConfig; + containerConfig?: ContainerConfig; + @doc("Dockerfile used for building a new container encoded in base64 (optional). When provided, the attribute `containerImage` must be omitted.") containerFile?: string; - containerImage: string; - containerIncludes: string[]; + @doc("Name of the container to be served, e.g., `docker.io/library/ubuntu:latest` (optional). If omitted, the `containerFile` must be provided. ") + containerImage?: string; + @doc("List of container images to include in the built container (optional).") + containerIncludes?: string[]; + @doc("Target container architecture of the built container, e.g., `linux/amd64` (optional). Currently only supporting amd64 and arm64.") containerPlatform: string; - dryRun: boolean; + @doc("Request to build the container in a dry-run mode.") + dryRun?: boolean; + @doc("Request unique fingerprint.") fingerprint?: string; + @doc("The format of the container to be built. Its values can be `sif` for singularity or `docker` as default. ") format: "sif" | "docker"; + @doc("Freeze requires buildRepository to push the build container to a user-defined repository. This provides the container URL from the user-defined repository, not the Wave generated URL. This URL won't change.") freeze?: boolean; + @doc("The name strategy to be used to create the name of the container built by Wave. Its values can be `none`, `tagPrefix`, or `imageSuffix`. ") nameStrategy?: "none" | "tagPrefix" | "imageSuffix"; mirror?: boolean; - packages?: Packages; + @doc("Conda packages to be installed in the container.") + packages?: CondaPackages; scanMode?: ScanMode; scanLevels?: ScanLevel[]; - timestamp: string; + @doc("Request submission timestamp using ISO-8601.") + timestamp?: string; + @doc("Access token of the user account granting access to the Seqera Platform service specified via `towerEndpoint` (optional).") towerAccessToken?: string; + @doc("Seqera Platform service endpoint from where container registry credentials are retrieved (optional). Default `https://api.cloud.seqera.io`.") towerEndpoint?: string; + @doc("Token to refresh ``towerAccessToken` after it become invalid (optional).") towerRefreshToken?: string; - towerWorkspaceId?: int32; - workflowId: string; + @doc("ID of the Seqera Platform workspace from where the container registry credentials are retrieved (optional). When omitted the personal workspace is used.") + towerWorkspaceId?: int64; + @doc("ID of the Seqera Platform workspace from which this container request originates (optional).") + workflowId?: string; } diff --git a/typespec/models/ContainerResponse.tsp b/typespec/models/ContainerResponse.tsp index 3d8c83865..0867abea5 100644 --- a/typespec/models/ContainerResponse.tsp +++ b/typespec/models/ContainerResponse.tsp @@ -1,16 +1,40 @@ import "./ContainerStatus.tsp"; @doc("Response payload for container token creation.") +@example(#{ + containerToken:"732b73aa17c8", + targetImage:"wave.seqera.io/wt/732b73aa17c8/build/dev:salmon_bwa--5e49881e6ad74121", + expiration:"2024-04-09T21:19:01.715321Z", + buildId:"5e49881e6ad74121_1", + cached:false, + freeze:false, + mirror:false, + requestId:"5e49881e6ad74121", + scanId:"5e49881e6ad74121", + containerImage:"docker.io/build/dev:salmon_bwa--5e49881e6ad74121", + status:ContainerStatus.PENDING + }) model ContainerResponse { + @doc("Unique identifier for the build.") buildId: string; + @doc("Indicates if the build is cached.") cached: boolean; + @doc("Container image to be used.") containerImage: string; + @doc("Token to access the container.") containerToken: string; + @doc("The expiration timestamp of the Wave container using ISO-8601 format.") expiration: string; + @doc("Indicates if the build is pushed to user container registry.") freeze: boolean; + @doc("Indicates if its a mirror request.") mirror: boolean; + @doc("Unique identifier for the request.") requestId: string; + @doc("Unique identifier for the scan.") scanId: string; + @doc("Status of the container build.") status: ContainerStatus; + @doc("The Wave container image name") targetImage: string; } diff --git a/typespec/models/ContainerStatusResponse.tsp b/typespec/models/ContainerStatusResponse.tsp index 93d775d62..d7701338f 100644 --- a/typespec/models/ContainerStatusResponse.tsp +++ b/typespec/models/ContainerStatusResponse.tsp @@ -1,15 +1,25 @@ import "./ContainerStatus.tsp"; @doc("Response payload for container status.") +@example(#{ + id:"6c084f2e43f86a78", + buildId:"6c084f2e43f86a78_1", + status:ContainerStatus.DONE, + creationTime:"2024-04-09T20:31:35.355423Z", + detailsUri:"https://wave.seqera.io/view/builds/6c084f2e43f86a78_1", + duration:"123.914989000", + succeeded:true, + scanId:"6c084f2e43f86a78_1", +}) model ContainerStatusResponse { id: string; status: ContainerStatus; buildId: string; - mirrorId: string; + mirrorId?: string; scanId: string; - vulnerabilities: Record; + vulnerabilities?: Record; succeeded: boolean; - reason: string; + reason?: string; detailsUri: string; creationTime: string; duration: string; diff --git a/typespec/models/Manifest.tsp b/typespec/models/Manifest.tsp index 60f93bcc2..0906b90da 100644 --- a/typespec/models/Manifest.tsp +++ b/typespec/models/Manifest.tsp @@ -1,6 +1,22 @@ import "./ManifestLayer.tsp"; @doc("Manifest details of a container.") +@example(#{ + config: #{ + digest: "sha256:6c084f2e43f86a78", + mediaType: "application/vnd.docker.container.image.v1+json", + size: 1234 + }, + layers: #[ + #{ + digest: "sha256:6c084f2e43f86a78", + mediaType: "application/vnd.docker.container.image.v1+json", + size: 1234 + } + ], + mediaType: "application/vnd.docker.container.image.v1+json", + schemaVersion: 2 +}) model Manifest { config: { digest: string; @@ -10,4 +26,4 @@ model Manifest { layers: ManifestLayer[]; mediaType: string; schemaVersion: int32; -} \ No newline at end of file +} diff --git a/typespec/models/ManifestLayer.tsp b/typespec/models/ManifestLayer.tsp index 418904eb9..d3d24eb66 100644 --- a/typespec/models/ManifestLayer.tsp +++ b/typespec/models/ManifestLayer.tsp @@ -1,6 +1,11 @@ @doc("Manifest layer details of a container.") +@example(#{ + digest: "sha256:6c084f2e43f86a78", + mediaType: "application/vnd.docker.container.image.v1+json", + size: 1234 +}) model ManifestLayer { digest: string; mediaType: string; size: int64; -} \ No newline at end of file +} diff --git a/typespec/models/Packages.tsp b/typespec/models/Packages.tsp deleted file mode 100644 index ce534501b..000000000 --- a/typespec/models/Packages.tsp +++ /dev/null @@ -1,10 +0,0 @@ -import "./CondaOpts.tsp"; - -@doc("Package configurations for container builds.") -model Packages { - channels: string[]; - condaOpts?: CondaOpts; - entries: string[]; - environment: string; - type: "CONDA"; -} diff --git a/typespec/models/RootFS.tsp b/typespec/models/RootFS.tsp index 2ec6be063..ebb4d8a73 100644 --- a/typespec/models/RootFS.tsp +++ b/typespec/models/RootFS.tsp @@ -1,4 +1,11 @@ @doc("Details about the root filesystem of a container.") +@example(#{ + diff_ids: #[ + "sha256:6c084f2e43f86a78", + "sha256:6c084f2e43f86a78" + ], + type: "layers" +}) model RootFS { diff_ids: string[]; type: string; diff --git a/typespec/models/User.tsp b/typespec/models/User.tsp index 883494f13..14ad84809 100644 --- a/typespec/models/User.tsp +++ b/typespec/models/User.tsp @@ -1,4 +1,9 @@ @doc("Wave USer details") +@example(#{ + id: 1, + userName: "test", + email: "test@seqera.io" + }) model User { id: int64; userName: string; diff --git a/typespec/models/ValidateRegistryCredsRequest.tsp b/typespec/models/ValidateRegistryCredsRequest.tsp index c1ef4ba95..7fe0049d2 100644 --- a/typespec/models/ValidateRegistryCredsRequest.tsp +++ b/typespec/models/ValidateRegistryCredsRequest.tsp @@ -1,7 +1,11 @@ @doc("request payload of validate credentials request") +@example(#{ + password: "password", + registry: "docker.io/wave", + userName: "username" +}) model ValidateRegistryCredsRequest { password: string; registry: string; userName: string; } - \ No newline at end of file diff --git a/typespec/models/Vulnerability.tsp b/typespec/models/Vulnerability.tsp index 9a4e5339c..59cd5d899 100644 --- a/typespec/models/Vulnerability.tsp +++ b/typespec/models/Vulnerability.tsp @@ -1,4 +1,13 @@ @doc("Scan Vulnerability details") +@example(#{ + fixedVersion: "1.0.0", + id: "CVE-2021-1234", + installedVersion: "0.9.0", + pkgName: "test", + primaryUrl: "https://test.com", + severity: "high", + title: "test" +}) model Vulnerability { fixedVersion: string; id: string; @@ -7,4 +16,4 @@ model Vulnerability { primaryUrl: string; severity: string; title: string; - } \ No newline at end of file + } diff --git a/typespec/models/WaveBuildRecord.tsp b/typespec/models/WaveBuildRecord.tsp index 50f6966d1..4c4bc6069 100644 --- a/typespec/models/WaveBuildRecord.tsp +++ b/typespec/models/WaveBuildRecord.tsp @@ -1,3 +1,4 @@ +@doc("Wave container build details") model WaveBuildRecord { buildId: string; condaFile: string; diff --git a/typespec/routes.tsp b/typespec/routes.tsp index dd1a8678c..fb65157a7 100644 --- a/typespec/routes.tsp +++ b/typespec/routes.tsp @@ -15,12 +15,14 @@ namespace wave { @route("/v1alpha2/container") interface ContainerService { + @doc("This endpoint allows you to submit a request to access a private container registry via Wave, or build a container image on-the-fly with a Dockerfile or Conda recipe file and returns the name of the container request made available by Wave.") @post op createContainer(@body requestBody: ContainerRequest): { @body response: ContainerResponse; @statusCode statusCode: 200; }; @route("/{requestId}") + @doc("This endpoint allows you to get the details of a container request made to Wave.") @get op getContainerDetails(@path requestId: string): { @body response: WaveContainerRecord; @statusCode statusCode: 200; @@ -29,6 +31,7 @@ namespace wave { }; @route("/{requestId}/status") + @doc("This endpoint allows you to get the status of a container request made to Wave.") @get op getContainerStatus(@path requestId: string): { @body response: ContainerStatusResponse; @statusCode statusCode: 200; @@ -40,6 +43,7 @@ namespace wave { @route("/v1alpha1/builds/{buildId}") interface BuildService { + @doc("Provides status of build against buildId passed as path variable.") @get op getBuildRecord(@path buildId: string): { @body response: WaveBuildRecord; @statusCode statusCode: 200; @@ -48,6 +52,7 @@ namespace wave { }; @route("/status") + @doc("Provides status of build against buildId passed as path variable.") @get op getBuildStatus(@path buildId: string): { @body response: BuildStatusResponse; @statusCode statusCode: 200; @@ -56,6 +61,7 @@ namespace wave { }; @route("/logs") + @doc("Supply logs corresponding to the specified buildId within the API request.") @get op getBuildLogs(@path buildId: string): { @body response: string; @statusCode statusCode: 200; @@ -67,7 +73,7 @@ namespace wave { @route("/v1alpha1/scans/{scanId}") interface scanService{ - + @doc("This endpoint allows you to get the details of a container scan request made to Wave.") @get op scanImage(@path scanId: string) : { @body response: WaveScanRecord; @statusCode statusCode: 200; @@ -79,7 +85,7 @@ namespace wave { @route("/v1alpha1/inspect") interface InspectService { - + @doc("This endpoint returns the metadata about provided container image.") @post op inspectContainer(@body requestBody: ContainerInspectRequest): { @body response: ContainerInspectResponse; @statusCode statusCode: 200; @@ -90,12 +96,14 @@ namespace wave { } @route("/v1alpha2/validate-creds") + @doc("This endpoint allows you to validate the credentials of a container registry.") @post op validateCredsV2(@body request: ValidateRegistryCredsRequest): boolean; @route("/v1alpha1/mirrors") interface getMirrorRecord { @route("/{mirrorId}") + @doc("This endpoint allows you to get the details of a container mirror request made to Wave.") @get op containerMirror(@path mirrorId: string): { @body response: ContainerMirrorResponse; @statusCode statusCode: 200; From 1d4bc1949b925eb5a24271a9484e7d5057293814 Mon Sep 17 00:00:00 2001 From: Munish Chouhan Date: Thu, 27 Feb 2025 19:52:18 +0100 Subject: [PATCH 2/9] Use post for container scan (#802) Signed-off-by: munishchouhan --- .../groovy/io/seqera/wave/controller/ViewController.groovy | 7 ++++--- .../io/seqera/wave/controller/ViewControllerTest.groovy | 4 ++-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/main/groovy/io/seqera/wave/controller/ViewController.groovy b/src/main/groovy/io/seqera/wave/controller/ViewController.groovy index b8d230519..9233358dc 100644 --- a/src/main/groovy/io/seqera/wave/controller/ViewController.groovy +++ b/src/main/groovy/io/seqera/wave/controller/ViewController.groovy @@ -28,8 +28,10 @@ import io.micronaut.core.annotation.Nullable import io.micronaut.http.HttpRequest import io.micronaut.http.HttpResponse import io.micronaut.http.HttpStatus +import io.micronaut.http.annotation.Body import io.micronaut.http.annotation.Controller import io.micronaut.http.annotation.Get +import io.micronaut.http.annotation.Post import io.micronaut.http.annotation.QueryValue import io.micronaut.http.client.HttpClient import io.micronaut.http.client.annotation.Client @@ -56,7 +58,6 @@ import io.seqera.wave.service.scan.ScanEntry import io.seqera.wave.service.scan.ScanVulnerability import io.seqera.wave.util.JacksonHelper import jakarta.inject.Inject -import jakarta.ws.rs.QueryParam import org.reactivestreams.Publisher import org.reactivestreams.Subscriber import org.reactivestreams.Subscription @@ -315,8 +316,8 @@ class ViewController { * @return * The redirect response to the scan view for the requested container image */ - @Get('/scans') - Publisher requestScan(@QueryParam String image) { + @Post('/scans') + Publisher requestScan(@Body String image) { final req = new SubmitContainerTokenRequest(containerImage: image, scanMode: ScanMode.required) final post = HttpRequest.POST("/v1alpha2/container", req) final resp = httpClient.retrieve(post, SubmitContainerTokenResponse) diff --git a/src/test/groovy/io/seqera/wave/controller/ViewControllerTest.groovy b/src/test/groovy/io/seqera/wave/controller/ViewControllerTest.groovy index 8bb2a029d..7fd4c9de5 100644 --- a/src/test/groovy/io/seqera/wave/controller/ViewControllerTest.groovy +++ b/src/test/groovy/io/seqera/wave/controller/ViewControllerTest.groovy @@ -790,8 +790,8 @@ class ViewControllerTest extends Specification { def image = "ubuntu:latest" and: def req = java.net.http.HttpRequest.newBuilder() - .GET() - .uri(new URI("${uri}/view/scans?image=${image}")) + .POST(java.net.http.HttpRequest.BodyPublishers.ofByteArray(image.bytes)) + .uri(new URI("${uri}/view/scans")) .build() when: From 3aec0412845d3e185a169455d2bd727c4056e098 Mon Sep 17 00:00:00 2001 From: Paolo Di Tommaso Date: Thu, 27 Feb 2025 19:53:27 +0100 Subject: [PATCH 3/9] [release] bump version 1.18.2 Signed-off-by: Paolo Di Tommaso --- VERSION | 2 +- changelog.txt | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/VERSION b/VERSION index ec6d649be..b57fc7228 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.18.1 +1.18.2 diff --git a/changelog.txt b/changelog.txt index 034fdf21a..b8bb69522 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,4 +1,8 @@ # Wave changelog +1.18.2 - 27 Feb 2025 +- Use post for container scan (#802) [1d4bc194] +- Wv 102 merge https docs.seqera.io wave api into openapi (#800) [206881ec] + 1.18.1 - 21 Feb 2025 - Add denyHosts to pairing websocket [f5369eed] - Use virtual threads for build, scan and mirror jobs (#742) [9dfce1f7] From edfae007c85d347e279a49b5db7c9b6d69f2917d Mon Sep 17 00:00:00 2001 From: Paolo Di Tommaso Date: Fri, 28 Feb 2025 11:41:09 +0100 Subject: [PATCH 4/9] Add DenyCrawlerFilter (#803) Signed-off-by: Paolo Di Tommaso --- .../controller/ServiceInfoController.groovy | 2 +- .../wave/filter/DenyCrawlerFilter.groovy | 78 +++++++++++++++++++ .../io/seqera/wave/filter/FilterOrder.groovy | 1 + .../service/data/stream/MessageStream.groovy | 2 +- .../controller/InspectControllerTest.groovy | 1 - .../wave/controller/ScanControllerTest.groovy | 3 +- .../ServiceInfoControllerTest.groovy | 50 ++++++++++-- 7 files changed, 125 insertions(+), 12 deletions(-) create mode 100644 src/main/groovy/io/seqera/wave/filter/DenyCrawlerFilter.groovy diff --git a/src/main/groovy/io/seqera/wave/controller/ServiceInfoController.groovy b/src/main/groovy/io/seqera/wave/controller/ServiceInfoController.groovy index 85d43dd4a..80ecd495f 100644 --- a/src/main/groovy/io/seqera/wave/controller/ServiceInfoController.groovy +++ b/src/main/groovy/io/seqera/wave/controller/ServiceInfoController.groovy @@ -59,7 +59,7 @@ class ServiceInfoController { : HttpResponse.badRequest() } - @Get(uri = "/openapi") + @Get("/openapi") HttpResponse getOpenAPI() { HttpResponse.redirect(URI.create("/openapi/")) } diff --git a/src/main/groovy/io/seqera/wave/filter/DenyCrawlerFilter.groovy b/src/main/groovy/io/seqera/wave/filter/DenyCrawlerFilter.groovy new file mode 100644 index 000000000..593eedd4b --- /dev/null +++ b/src/main/groovy/io/seqera/wave/filter/DenyCrawlerFilter.groovy @@ -0,0 +1,78 @@ +/* + * Wave, containers provisioning service + * Copyright (c) 2023-2024, Seqera Labs + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package io.seqera.wave.filter + +import groovy.transform.CompileStatic +import groovy.util.logging.Slf4j +import io.micronaut.http.HttpRequest +import io.micronaut.http.HttpResponse +import io.micronaut.http.HttpStatus +import io.micronaut.http.MutableHttpResponse +import io.micronaut.http.annotation.Filter +import io.micronaut.http.filter.HttpServerFilter +import io.micronaut.http.filter.ServerFilterChain +import org.reactivestreams.Publisher +import reactor.core.publisher.Flux +/** + * Block the access to known crawler bots + * + * @author Paolo Di Tommaso + */ +@Slf4j +@CompileStatic +@Filter("/**") +class DenyCrawlerFilter implements HttpServerFilter { + + private static final List CRAWLER_AGENTS = Arrays.asList( + "googlebot", + "bingbot", + "yandexbot", + "baiduspider", + "duckduckbot", + "slurp", + "facebot", + "twitterbot", + "mj12bot", + "ahrefsbot" + ) + + static boolean isCrawler(String userAgent) { + return userAgent + ? CRAWLER_AGENTS.stream().anyMatch(userAgent::contains) + : false + } + + @Override + Publisher> doFilter(HttpRequest request, ServerFilterChain chain) { + final userAgent = request.getHeaders().get("User-Agent")?.toLowerCase() + // Check if the request path matches any of the ignored paths + if (isCrawler(userAgent)) { + // Return immediately without processing the request + log.debug("Request denied: ${request}") + return Flux.just(HttpResponse.status(HttpStatus.METHOD_NOT_ALLOWED)) + } + // Continue processing the request + return chain.proceed(request) + } + + @Override + int getOrder() { + return FilterOrder.DENY_CRAWLER + } +} diff --git a/src/main/groovy/io/seqera/wave/filter/FilterOrder.groovy b/src/main/groovy/io/seqera/wave/filter/FilterOrder.groovy index 057d4fcca..0c8919690 100644 --- a/src/main/groovy/io/seqera/wave/filter/FilterOrder.groovy +++ b/src/main/groovy/io/seqera/wave/filter/FilterOrder.groovy @@ -27,6 +27,7 @@ package io.seqera.wave.filter */ interface FilterOrder { + final int DENY_CRAWLER = -110 final int DENY_PATHS = -100 final int RATE_LIMITER = -50 final int PULL_METRICS = 10 diff --git a/src/main/groovy/io/seqera/wave/service/data/stream/MessageStream.groovy b/src/main/groovy/io/seqera/wave/service/data/stream/MessageStream.groovy index cb77741b4..5f0ae3d91 100644 --- a/src/main/groovy/io/seqera/wave/service/data/stream/MessageStream.groovy +++ b/src/main/groovy/io/seqera/wave/service/data/stream/MessageStream.groovy @@ -28,7 +28,7 @@ interface MessageStream { /** * Initialize the stream with the given Id * - * @param streamId The uniqur ID of the stream to be initialized + * @param streamId The unique ID of the stream to be initialized */ void init(String streamId) diff --git a/src/test/groovy/io/seqera/wave/controller/InspectControllerTest.groovy b/src/test/groovy/io/seqera/wave/controller/InspectControllerTest.groovy index 98788cf6c..de1324258 100644 --- a/src/test/groovy/io/seqera/wave/controller/InspectControllerTest.groovy +++ b/src/test/groovy/io/seqera/wave/controller/InspectControllerTest.groovy @@ -29,7 +29,6 @@ import io.micronaut.test.annotation.MockBean import io.micronaut.test.extensions.spock.annotation.MicronautTest import io.seqera.wave.api.ContainerInspectRequest import io.seqera.wave.api.ContainerInspectResponse -import io.seqera.wave.exception.BadRequestException import io.seqera.wave.service.logs.BuildLogService import io.seqera.wave.service.logs.BuildLogServiceImpl import jakarta.inject.Inject diff --git a/src/test/groovy/io/seqera/wave/controller/ScanControllerTest.groovy b/src/test/groovy/io/seqera/wave/controller/ScanControllerTest.groovy index 5677bf77a..013132a62 100644 --- a/src/test/groovy/io/seqera/wave/controller/ScanControllerTest.groovy +++ b/src/test/groovy/io/seqera/wave/controller/ScanControllerTest.groovy @@ -92,11 +92,10 @@ class ScanControllerTest extends Specification { res.body().requestId == scan.requestId } - def "should return 404 and null"() { when: def req = HttpRequest.GET("/v1alpha1/scans/unknown") - def res = client.toBlocking().exchange(req, WaveScanRecord) + client.toBlocking().exchange(req, WaveScanRecord) then: def e = thrown(HttpClientResponseException) diff --git a/src/test/groovy/io/seqera/wave/controller/ServiceInfoControllerTest.groovy b/src/test/groovy/io/seqera/wave/controller/ServiceInfoControllerTest.groovy index c79ef7d91..dd3c7795a 100644 --- a/src/test/groovy/io/seqera/wave/controller/ServiceInfoControllerTest.groovy +++ b/src/test/groovy/io/seqera/wave/controller/ServiceInfoControllerTest.groovy @@ -20,25 +20,61 @@ package io.seqera.wave.controller import spock.lang.Specification -import io.micronaut.http.HttpResponse +import io.micronaut.http.HttpRequest import io.micronaut.http.HttpStatus - +import io.micronaut.http.client.DefaultHttpClientConfiguration +import io.micronaut.http.client.HttpClient +import io.micronaut.http.client.annotation.Client +import io.micronaut.http.client.exceptions.HttpClientResponseException +import io.micronaut.runtime.server.EmbeddedServer +import io.micronaut.test.extensions.spock.annotation.MicronautTest +import jakarta.inject.Inject /** * * @author Munish Chouhan */ +@MicronautTest class ServiceInfoControllerTest extends Specification { + @Inject + @Client("/") + HttpClient client + + @Inject + EmbeddedServer embeddedServer; + + def 'should get service info' () { + when: + def request = HttpRequest.GET("/service-info") + def resp = client.toBlocking().exchange(request, String) + then: + resp.status.code == 200 + } + + def 'should deny service info' () { + when: + def request = HttpRequest.GET("/service-info").header('User-Agent','Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)') + client.toBlocking().exchange(request, String) + then: + def e = thrown(HttpClientResponseException) + e.status == HttpStatus.METHOD_NOT_ALLOWED + } + def 'should redirect to /openapi/'() { given: - def controller = new ServiceInfoController() - + def uri = embeddedServer.getContextURI() + and: + // Create a new HttpClient with redirects disabled + def config = new DefaultHttpClientConfiguration() + config.setFollowRedirects(false) + def client = HttpClient.create(uri.toURL(), config) when: - HttpResponse response = controller.getOpenAPI() + def request = HttpRequest.GET("/openapi") + def resp = client.toBlocking().exchange(request, String) then: - response.status == HttpStatus.MOVED_PERMANENTLY - response.header('Location') == '/openapi/' + resp.status == HttpStatus.MOVED_PERMANENTLY // Expect 301 + resp.headers.get("Location") == "/openapi/" // Validate redirect location } } From 3787055f6b107d821ad702196725c2918494b33a Mon Sep 17 00:00:00 2001 From: Paolo Di Tommaso Date: Fri, 28 Feb 2025 11:46:25 +0100 Subject: [PATCH 5/9] [release] bump version Signed-off-by: Paolo Di Tommaso --- VERSION | 2 +- changelog.txt | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/VERSION b/VERSION index b57fc7228..b9fb27ab4 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.18.2 +1.18.3 diff --git a/changelog.txt b/changelog.txt index b8bb69522..9df1b716c 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,4 +1,7 @@ # Wave changelog +1.18.3 - 28 Feb 2025 +- Add DenyCrawlerFilter (#803) [edfae007] + 1.18.2 - 27 Feb 2025 - Use post for container scan (#802) [1d4bc194] - Wv 102 merge https docs.seqera.io wave api into openapi (#800) [206881ec] From 216b8227aa492875f0036fcbeea2d5c04d46764f Mon Sep 17 00:00:00 2001 From: Paolo Di Tommaso Date: Sat, 1 Mar 2025 11:02:53 +0100 Subject: [PATCH 6/9] Improve warning loging Signed-off-by: Paolo Di Tommaso --- .../groovy/io/seqera/wave/ErrorHandler.groovy | 25 ++++++++++++------- .../wave/core/RegistryProxyService.groovy | 4 +-- .../wave/filter/DenyCrawlerFilter.groovy | 3 ++- .../seqera/wave/filter/DenyPathsFilter.groovy | 3 ++- .../io/seqera/wave/proxy/ProxyClient.groovy | 2 +- .../io/seqera/wave/util/RegHelper.groovy | 17 ++++++++++--- 6 files changed, 37 insertions(+), 17 deletions(-) diff --git a/src/main/groovy/io/seqera/wave/ErrorHandler.groovy b/src/main/groovy/io/seqera/wave/ErrorHandler.groovy index 69a4ab430..3d2620aa4 100644 --- a/src/main/groovy/io/seqera/wave/ErrorHandler.groovy +++ b/src/main/groovy/io/seqera/wave/ErrorHandler.groovy @@ -18,6 +18,7 @@ package io.seqera.wave +import groovy.transform.CompileStatic import groovy.util.logging.Slf4j import io.micronaut.context.annotation.Value import io.micronaut.http.HttpRequest @@ -36,6 +37,7 @@ import io.seqera.wave.exception.SlowDownException import io.seqera.wave.exception.UnauthorizedException import io.seqera.wave.exception.WaveException import io.seqera.wave.util.LongRndKey +import io.seqera.wave.util.RegHelper import jakarta.inject.Singleton /** * Common error handling logic @@ -44,6 +46,7 @@ import jakarta.inject.Singleton */ @Slf4j @Singleton +@CompileStatic class ErrorHandler { static interface Mapper { @@ -53,17 +56,17 @@ class ErrorHandler { @Value('${wave.debug:false}') private Boolean debug - def HttpResponse handle(HttpRequest httpRequest, Throwable t, Mapper responseFactory) { + HttpResponse handle(HttpRequest request, Throwable t, Mapper responseFactory) { final errId = LongRndKey.rndHex() - final request = httpRequest?.toString() final knownException = t instanceof WaveException || t instanceof HttpStatusException - def msg = t.message + String msg = t.message if( knownException && msg ) { // the the error cause if( t.cause ) msg += " - Cause: ${t.cause.message ?: t.cause}".toString() // render the message for logging - def render = msg - if( request ) render += " - Request: ${request}" + String render = msg + if( request ) + render += toString(request) if( !debug ) { log.warn(render) } @@ -78,8 +81,9 @@ class ErrorHandler { msg = "Oops... Unable to process request" msg += " - Error ID: ${errId}" // render the message for logging - def render = msg - if( request ) render += " - Request: ${request}" + String render = msg + if( request ) + render += toString(request) log.error(render, t) } @@ -92,10 +96,10 @@ class ErrorHandler { if( t instanceof RegistryForwardException ) { // report this error as it has been returned by the target registry - return HttpResponse + return (HttpResponse) HttpResponse .status(HttpStatus.valueOf(t.statusCode)) .body(t.response) - .headers(t.headers) + .headers(t.headers as Map) } if( t instanceof DockerRegistryException ) { @@ -143,4 +147,7 @@ class ErrorHandler { } + static String toString(HttpRequest request) { + "\n- Request: [${request.methodName}] ${request.uri}\n- Headers:${RegHelper.dumpHeaders(request)}" + } } diff --git a/src/main/groovy/io/seqera/wave/core/RegistryProxyService.groovy b/src/main/groovy/io/seqera/wave/core/RegistryProxyService.groovy index c143da70c..5b2615a0c 100644 --- a/src/main/groovy/io/seqera/wave/core/RegistryProxyService.groovy +++ b/src/main/groovy/io/seqera/wave/core/RegistryProxyService.groovy @@ -265,7 +265,7 @@ class RegistryProxyService { log.warn "Unexpected redirect location '${redirect}' with status code: ${status}" } else if( status>=300 && status<400 ) { - log.warn "Unexpected redirect status code: ${status}; headers: ${RegHelper.dumpHeaders(resp1.headers())}" + log.warn "Unexpected redirect status code: ${status}; headers: ${RegHelper.dumpHeaders(resp1)}" } final len = resp1.headers().firstValueAsLong('Content-Length').orElse(0) @@ -331,7 +331,7 @@ class RegistryProxyService { final resp = proxyClient.head(route.path, WaveDefault.ACCEPT_HEADERS) final result = resp.headers().firstValue('docker-content-digest').orElse(null) if( !result && (resp.statusCode()!=404 || retryOnNotFound) ) { - log.warn "Unable to retrieve digest for image '$image' -- response status=${resp.statusCode()}; headers:\n${RegHelper.dumpHeaders(resp.headers())}" + log.warn "Unable to retrieve digest for image '$image' -- response status=${resp.statusCode()}; headers:\n${RegHelper.dumpHeaders(resp)}" } return result } diff --git a/src/main/groovy/io/seqera/wave/filter/DenyCrawlerFilter.groovy b/src/main/groovy/io/seqera/wave/filter/DenyCrawlerFilter.groovy index 593eedd4b..715ac4e34 100644 --- a/src/main/groovy/io/seqera/wave/filter/DenyCrawlerFilter.groovy +++ b/src/main/groovy/io/seqera/wave/filter/DenyCrawlerFilter.groovy @@ -27,6 +27,7 @@ import io.micronaut.http.MutableHttpResponse import io.micronaut.http.annotation.Filter import io.micronaut.http.filter.HttpServerFilter import io.micronaut.http.filter.ServerFilterChain +import io.seqera.wave.util.RegHelper import org.reactivestreams.Publisher import reactor.core.publisher.Flux /** @@ -64,7 +65,7 @@ class DenyCrawlerFilter implements HttpServerFilter { // Check if the request path matches any of the ignored paths if (isCrawler(userAgent)) { // Return immediately without processing the request - log.debug("Request denied: ${request}") + log.warn("Request denied [${request.methodName}] ${request.uri}\n- Headers:${RegHelper.dumpHeaders(request)}") return Flux.just(HttpResponse.status(HttpStatus.METHOD_NOT_ALLOWED)) } // Continue processing the request diff --git a/src/main/groovy/io/seqera/wave/filter/DenyPathsFilter.groovy b/src/main/groovy/io/seqera/wave/filter/DenyPathsFilter.groovy index 580988aac..f74d67512 100644 --- a/src/main/groovy/io/seqera/wave/filter/DenyPathsFilter.groovy +++ b/src/main/groovy/io/seqera/wave/filter/DenyPathsFilter.groovy @@ -29,6 +29,7 @@ import io.micronaut.http.MutableHttpResponse import io.micronaut.http.annotation.Filter import io.micronaut.http.filter.HttpServerFilter; import io.micronaut.http.filter.ServerFilterChain +import io.seqera.wave.util.RegHelper import org.reactivestreams.Publisher import reactor.core.publisher.Flux @@ -53,7 +54,7 @@ class DenyPathsFilter implements HttpServerFilter { // Check if the request path matches any of the ignored paths if (isDeniedPath(request.path, deniedPaths)) { // Return immediately without processing the request - log.debug("Request denied: ${request}") + log.warn("Request denied [${request.methodName}] ${request.uri}\n- Headers:${RegHelper.dumpHeaders(request)}") return Flux.just(HttpResponse.status(HttpStatus.METHOD_NOT_ALLOWED)) } // Continue processing the request diff --git a/src/main/groovy/io/seqera/wave/proxy/ProxyClient.groovy b/src/main/groovy/io/seqera/wave/proxy/ProxyClient.groovy index 8f1664a72..a875f8a44 100644 --- a/src/main/groovy/io/seqera/wave/proxy/ProxyClient.groovy +++ b/src/main/groovy/io/seqera/wave/proxy/ProxyClient.groovy @@ -195,7 +195,7 @@ class ProxyClient { } if( result.statusCode() in HTTP_REDIRECT_CODES && followRedirect ) { final redirect = result.headers().firstValue('location').orElse(null) - log.trace "Redirecting (${++redirectCount}) $target ==> $redirect ${RegHelper.dumpHeaders(result.headers())}" + log.trace "Redirecting (${++redirectCount}) $target ==> $redirect ${RegHelper.dumpHeaders(result)}" if( !redirect ) { final msg = "Missing `Location` header for request URI '$target' ― origin request '$origin'" throw new ClientResponseException(msg, result.request()) diff --git a/src/main/groovy/io/seqera/wave/util/RegHelper.groovy b/src/main/groovy/io/seqera/wave/util/RegHelper.groovy index 4ffc5a225..4d551711f 100644 --- a/src/main/groovy/io/seqera/wave/util/RegHelper.groovy +++ b/src/main/groovy/io/seqera/wave/util/RegHelper.groovy @@ -18,7 +18,6 @@ package io.seqera.wave.util -import java.net.http.HttpHeaders import java.net.http.HttpResponse import java.nio.charset.Charset import java.nio.file.Files @@ -105,8 +104,20 @@ class RegHelper { } } - static String dumpHeaders(HttpHeaders headers) { - return dumpHeaders(headers.map()) + static String dumpHeaders(io.micronaut.http.HttpRequest request) { + dumpHeaders(request.getHeaders().asMap()) + } + + static String dumpHeaders(io.micronaut.http.HttpResponse response) { + dumpHeaders(response.getHeaders().asMap()) + } + + static String dumpHeaders(java.net.http.HttpRequest request) { + return dumpHeaders(request.headers().map()) + } + + static String dumpHeaders(java.net.http.HttpResponse response) { + return dumpHeaders(response.headers().map()) } static String dumpHeaders(Map> headers) { From 40c91f5f794a032c9ca83faa12ba58612df23b9b Mon Sep 17 00:00:00 2001 From: Paolo Di Tommaso Date: Sat, 1 Mar 2025 13:34:41 +0100 Subject: [PATCH 7/9] Add robots and favicon files Signed-off-by: Paolo Di Tommaso --- .../wave/controller/ServiceInfoController.groovy | 12 ++++++++++++ .../resources/io/seqera/wave/assets/robots.txt | 2 ++ .../resources/io/seqera/wave/assets/wave.ico | Bin 0 -> 4286 bytes .../controller/ServiceInfoControllerTest.groovy | 15 +++++++++++++++ 4 files changed, 29 insertions(+) create mode 100644 src/main/resources/io/seqera/wave/assets/robots.txt create mode 100644 src/main/resources/io/seqera/wave/assets/wave.ico diff --git a/src/main/groovy/io/seqera/wave/controller/ServiceInfoController.groovy b/src/main/groovy/io/seqera/wave/controller/ServiceInfoController.groovy index 80ecd495f..b086f08fb 100644 --- a/src/main/groovy/io/seqera/wave/controller/ServiceInfoController.groovy +++ b/src/main/groovy/io/seqera/wave/controller/ServiceInfoController.groovy @@ -24,6 +24,7 @@ import io.micronaut.core.annotation.Nullable import groovy.util.logging.Slf4j import io.micronaut.context.annotation.Value import io.micronaut.http.HttpResponse +import io.micronaut.http.MediaType import io.micronaut.http.annotation.Controller import io.micronaut.http.annotation.Get import io.micronaut.scheduling.TaskExecutors @@ -64,4 +65,15 @@ class ServiceInfoController { HttpResponse.redirect(URI.create("/openapi/")) } + @Get(uri = "/favicon.ico", produces = MediaType.IMAGE_X_ICON) + HttpResponse getFavicon() { + final inputStream = getClass().getResourceAsStream("/io/seqera/wave/assets/wave.ico"); + return inputStream != null ? HttpResponse.ok(inputStream) : HttpResponse.notFound(); + } + + @Get(uri = "/robots.txt", produces = MediaType.TEXT_PLAIN) + HttpResponse getRobotsTxt() { + final inputStream = getClass().getResourceAsStream("/io/seqera/wave/assets/robots.txt"); + return inputStream != null ? HttpResponse.ok(inputStream) : HttpResponse.notFound(); + } } diff --git a/src/main/resources/io/seqera/wave/assets/robots.txt b/src/main/resources/io/seqera/wave/assets/robots.txt new file mode 100644 index 000000000..1f53798bb --- /dev/null +++ b/src/main/resources/io/seqera/wave/assets/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Disallow: / diff --git a/src/main/resources/io/seqera/wave/assets/wave.ico b/src/main/resources/io/seqera/wave/assets/wave.ico new file mode 100644 index 0000000000000000000000000000000000000000..bf447e4104320c1b972e74f71dba470b62937e4d GIT binary patch literal 4286 zcmeH}ziL!L6o+Tsm=%Q>v$_ZtZmjenLbfoiosiPf!XmwdwX_onqWA*t3#1LV6G5=E z6H@p9MnyE>j^8(#k!usk#3IGL;meuK%sJriU&lD2D9n?CvJ55~R^Z{ZjGli!K- z$pnhd_q08mC(f@=M*Ri*hHww8zw7e%{b)OW4Lp<9<{7L&ZGL@?VJu@BJH^t1xz(T+ zHL0!iw>8)1w|3UhT3gczwDY&mziZ;!xJItkiF39c^atO;b5!<|dn?7pI^ND-=BU%X zI&eT?60GI;?|tPtoa}G=rkS zJIlMPHhuEbAG}L5y{bn~Xf)eJ`)>vRp&*m`yo#N)U|D#x| zakdNgcTZP=t^#MT0`Ztf^apr_H Q9@F?fbAEoA9+M*e0db=P)c^nh literal 0 HcmV?d00001 diff --git a/src/test/groovy/io/seqera/wave/controller/ServiceInfoControllerTest.groovy b/src/test/groovy/io/seqera/wave/controller/ServiceInfoControllerTest.groovy index dd3c7795a..c38d5b398 100644 --- a/src/test/groovy/io/seqera/wave/controller/ServiceInfoControllerTest.groovy +++ b/src/test/groovy/io/seqera/wave/controller/ServiceInfoControllerTest.groovy @@ -77,4 +77,19 @@ class ServiceInfoControllerTest extends Specification { resp.headers.get("Location") == "/openapi/" // Validate redirect location } + def 'should get favicon' () { + when: + def request = HttpRequest.GET("/favicon.ico") + def resp = client.toBlocking().exchange(request, String) + then: + resp.status.code == 200 + } + + def 'should get robots' () { + when: + def request = HttpRequest.GET("/robots.txt") + def resp = client.toBlocking().exchange(request, String) + then: + resp.status.code == 200 + } } From b4df7d7cd2cdd1ef039a05511dccf6850fd6b7bc Mon Sep 17 00:00:00 2001 From: Paolo Di Tommaso Date: Mon, 3 Mar 2025 07:38:23 +0100 Subject: [PATCH 8/9] [release] bump version 1.18.4 Signed-off-by: Paolo Di Tommaso --- VERSION | 2 +- changelog.txt | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/VERSION b/VERSION index b9fb27ab4..a67b05e87 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.18.3 +1.18.4 diff --git a/changelog.txt b/changelog.txt index 9df1b716c..4613199ba 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,4 +1,8 @@ # Wave changelog +1.18.4 - 3 Mar 2025 +- Add robots and favicon files [40c91f5f] +- Improve errors and warns logging [216b8227] + 1.18.3 - 28 Feb 2025 - Add DenyCrawlerFilter (#803) [edfae007] From 1cf23c9ccd870a1aa03a0cbc5644762dfb401b14 Mon Sep 17 00:00:00 2001 From: Paolo Di Tommaso Date: Mon, 3 Mar 2025 11:40:05 +0100 Subject: [PATCH 9/9] Allow robots file for crawlers Signed-off-by: Paolo Di Tommaso --- .../wave/filter/DenyCrawlerFilter.groovy | 2 +- .../wave/filter/DenyCrawlerFilterTest.groovy | 58 +++++++++++++++++++ 2 files changed, 59 insertions(+), 1 deletion(-) create mode 100644 src/test/groovy/io/seqera/wave/filter/DenyCrawlerFilterTest.groovy diff --git a/src/main/groovy/io/seqera/wave/filter/DenyCrawlerFilter.groovy b/src/main/groovy/io/seqera/wave/filter/DenyCrawlerFilter.groovy index 715ac4e34..add0e3c39 100644 --- a/src/main/groovy/io/seqera/wave/filter/DenyCrawlerFilter.groovy +++ b/src/main/groovy/io/seqera/wave/filter/DenyCrawlerFilter.groovy @@ -63,7 +63,7 @@ class DenyCrawlerFilter implements HttpServerFilter { Publisher> doFilter(HttpRequest request, ServerFilterChain chain) { final userAgent = request.getHeaders().get("User-Agent")?.toLowerCase() // Check if the request path matches any of the ignored paths - if (isCrawler(userAgent)) { + if (isCrawler(userAgent) && request.path!='/robots.txt') { // Return immediately without processing the request log.warn("Request denied [${request.methodName}] ${request.uri}\n- Headers:${RegHelper.dumpHeaders(request)}") return Flux.just(HttpResponse.status(HttpStatus.METHOD_NOT_ALLOWED)) diff --git a/src/test/groovy/io/seqera/wave/filter/DenyCrawlerFilterTest.groovy b/src/test/groovy/io/seqera/wave/filter/DenyCrawlerFilterTest.groovy new file mode 100644 index 000000000..c68b51454 --- /dev/null +++ b/src/test/groovy/io/seqera/wave/filter/DenyCrawlerFilterTest.groovy @@ -0,0 +1,58 @@ +/* + * Wave, containers provisioning service + * Copyright (c) 2023-2024, Seqera Labs + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package io.seqera.wave.filter + +import spock.lang.Specification + +import io.micronaut.http.HttpRequest +import io.micronaut.http.client.HttpClient +import io.micronaut.http.client.annotation.Client +import io.micronaut.http.client.exceptions.HttpClientResponseException +import io.micronaut.test.extensions.spock.annotation.MicronautTest +import jakarta.inject.Inject + +/** + * + * @author Paolo Di Tommaso + */ +@MicronautTest +class DenyCrawlerFilterTest extends Specification { + + @Inject + @Client("/") + HttpClient client + + def 'should allow robots.txt' () { + when: + def request = HttpRequest.GET("/robots.txt").header("User-Agent", "Googlebot") + def resp = client.toBlocking().exchange(request, String) + then: + resp.status.code == 200 + } + + def 'should disallow anything else' () { + when: + def request = HttpRequest.GET("/service-info").header("User-Agent", "Googlebot") + client.toBlocking().exchange(request, String) + then: + HttpClientResponseException e = thrown(HttpClientResponseException) + e.status.code == 405 + } + +}