Compare commits

..

10 Commits

Author SHA1 Message Date
maximilien 19a29cce6d helm: fix test 2026-08-28 02:25:43 +02:00
maximilien d65be95051 helm: add unit test suite and complex valuefile example 2026-08-28 02:07:36 +02:00
maximilien ae2404027a helm: Ingress: only include yaml document delimiter when followed by content 2026-08-28 02:02:45 +02:00
maximilien 13d313134d helm: ServiceMonitor: validate tlsConfig and relabeling config before injecting it 2026-08-28 02:00:13 +02:00
maximilien 05e294307e Merge pull request 'doc: update on k2v inclusion in builds' (#1513) from vincent/garage:main-v2 into main-v2
Reviewed-on: https://git.deuxfleurs.fr/Deuxfleurs/garage/pulls/1513
2026-08-23 19:57:51 +00:00
Vincent 109fbd49b1 Update information about k2v inclusion in builds 2026-08-23 20:57:21 +02:00
i5-650 d0176f8e30 deregister from consul on shutdown (fix #1062) (#1507)
Reviewed-on: https://git.deuxfleurs.fr/Deuxfleurs/garage/pulls/1507
2026-08-22 17:19:51 +00:00
i5-650 d9b1dba137 1250 - add NO_COLOR env var to disable logs colors (#1503)
Fix #1250

Reviewed-on: https://git.deuxfleurs.fr/Deuxfleurs/garage/pulls/1503
Reviewed-by: Alex <lx@deuxfleurs.fr>
2026-08-19 08:03:44 +00:00
Agathe Porte c3c8af9a66 doc: quick-start: rework flag description (#1496)
Add a link to the manual install procedure, use a
quote to make the manual install path more visible
and mention the --default-access-key option.

Closes: #1446

Rendered output:

![image](/attachments/84bbe650-daff-40d1-b4bd-629abece27b0)

Reviewed-on: https://git.deuxfleurs.fr/Deuxfleurs/garage/pulls/1496
2026-07-25 14:48:53 +00:00
Alex Auvolat ad78b9ee5c CI: revert spurrious changed introduced in #1487 that broke CI for main-v2 (#1499)
Reviewed-on: https://git.deuxfleurs.fr/Deuxfleurs/garage/pulls/1499
2026-07-25 13:25:17 +00:00
41 changed files with 1386 additions and 406 deletions
+12
View File
@@ -53,3 +53,15 @@ steps:
- nix-build -j4 --attr flakePackages.dev
- nix-shell --attr ci --run ./script/test-smoke.sh || (cat /tmp/garage.log; false)
depends_on: [ build ]
- name: helm chart tests
image: helmunittest/helm-unittest:4.2.3-1.1.2
commands:
- helm lint --strict script/helm/garage
- helm lint --strict script/helm/garage -f script/helm/garage/tests/values/daemonset.yaml
- helm lint --strict script/helm/garage -f script/helm/garage/tests/values/ingress.yaml
- helm lint --strict script/helm/garage -f script/helm/garage/tests/values/existing-secret.yaml
- helm lint --strict script/helm/garage -f script/helm/garage/tests/values/monitoring.yaml
- helm lint --strict script/helm/garage -f script/helm/garage/tests/values/minimal.yaml
- helm lint --strict script/helm/garage -f script/helm/garage/complex-values.yaml
- helm unittest --strict script/helm/garage
+9 -197
View File
@@ -12,7 +12,7 @@
"name": "AGPL-3.0",
"identifier": "AGPL-3.0"
},
"version": "v2.4.0"
"version": "v2.3.0"
},
"servers": [
{
@@ -1243,36 +1243,6 @@
],
"description": "List all the buckets on the cluster with their UUID and their global and local aliases.",
"operationId": "ListBuckets",
"parameters": [
{
"name": "details",
"in": "query",
"description": "Returned detailed informations in the same format as GetBucketInfo for each bucket",
"required": false,
"schema": {
"type": "boolean"
}
},
{
"name": "offset",
"in": "query",
"description": "Bucket ID of the first bucket to return",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "limit",
"in": "query",
"description": "Maximum number of buckets to return in a single call",
"required": false,
"schema": {
"type": "integer",
"minimum": 0
}
}
],
"responses": {
"200": {
"description": "Returns the UUID of all the buckets and all their aliases",
@@ -1297,36 +1267,6 @@
],
"description": "Returns all API access keys in the cluster.",
"operationId": "ListKeys",
"parameters": [
{
"name": "details",
"in": "query",
"description": "Returned detailed informations in the same format as GetKeyInfo for each bucket",
"required": false,
"schema": {
"type": "boolean"
}
},
{
"name": "offset",
"in": "query",
"description": "Key ID of the first key to return",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "limit",
"in": "query",
"description": "Maximum number of keys to return in a single call",
"required": false,
"schema": {
"type": "integer",
"minimum": 0
}
}
],
"responses": {
"200": {
"description": "Returns the key identifier (aka `AWS_ACCESS_KEY_ID`) and its associated, human friendly, name if any (otherwise return an empty string)",
@@ -3260,20 +3200,10 @@
}
},
"ListBucketsResponse": {
"oneOf": [
{
"type": "array",
"items": {
"$ref": "#/components/schemas/ListBucketsResponseItem"
}
},
{
"type": "array",
"items": {
"$ref": "#/components/schemas/GetBucketInfoResponse"
}
}
]
"type": "array",
"items": {
"$ref": "#/components/schemas/ListBucketsResponseItem"
}
},
"ListBucketsResponseItem": {
"type": "object",
@@ -3306,20 +3236,10 @@
}
},
"ListKeysResponse": {
"oneOf": [
{
"type": "array",
"items": {
"$ref": "#/components/schemas/ListKeysResponseItem"
}
},
{
"type": "array",
"items": {
"$ref": "#/components/schemas/GetKeyInfoResponse"
}
}
]
"type": "array",
"items": {
"$ref": "#/components/schemas/ListKeysResponseItem"
}
},
"ListKeysResponseItem": {
"type": "object",
@@ -3401,35 +3321,10 @@
"dbEngine"
],
"properties": {
"addr": {
"type": [
"string",
"null"
],
"description": "Socket address used by other nodes to connect to this node for RPC"
},
"dataPartition": {
"oneOf": [
{
"type": "null"
},
{
"$ref": "#/components/schemas/FreeSpaceResp",
"description": "Total and available space on the disk partition(s) containing the data\ndirectory(ies)"
}
]
},
"dbEngine": {
"type": "string",
"description": "database engine used for metadata"
},
"draining": {
"type": [
"boolean",
"null"
],
"description": "Whether this node is part of an older layout version and is draining data."
},
"garageFeatures": {
"type": [
"array",
@@ -3451,38 +3346,9 @@
],
"description": "hostname of this node"
},
"isUp": {
"type": [
"boolean",
"null"
],
"description": "Whether this node is connected in the cluster"
},
"metadataPartition": {
"oneOf": [
{
"type": "null"
},
{
"$ref": "#/components/schemas/FreeSpaceResp",
"description": "Total and available space on the disk partition containing the\nmetadata directory"
}
]
},
"nodeId": {
"type": "string"
},
"role": {
"oneOf": [
{
"type": "null"
},
{
"$ref": "#/components/schemas/NodeAssignedRole",
"description": "Role assigned to this node in the current cluster layout"
}
]
},
"rustVersion": {
"type": "string",
"description": "rustc version with which this garage release was compiled"
@@ -3818,35 +3684,10 @@
"dbEngine"
],
"properties": {
"addr": {
"type": [
"string",
"null"
],
"description": "Socket address used by other nodes to connect to this node for RPC"
},
"dataPartition": {
"oneOf": [
{
"type": "null"
},
{
"$ref": "#/components/schemas/FreeSpaceResp",
"description": "Total and available space on the disk partition(s) containing the data\ndirectory(ies)"
}
]
},
"dbEngine": {
"type": "string",
"description": "database engine used for metadata"
},
"draining": {
"type": [
"boolean",
"null"
],
"description": "Whether this node is part of an older layout version and is draining data."
},
"garageFeatures": {
"type": [
"array",
@@ -3868,38 +3709,9 @@
],
"description": "hostname of this node"
},
"isUp": {
"type": [
"boolean",
"null"
],
"description": "Whether this node is connected in the cluster"
},
"metadataPartition": {
"oneOf": [
{
"type": "null"
},
{
"$ref": "#/components/schemas/FreeSpaceResp",
"description": "Total and available space on the disk partition containing the\nmetadata directory"
}
]
},
"nodeId": {
"type": "string"
},
"role": {
"oneOf": [
{
"type": "null"
},
{
"$ref": "#/components/schemas/NodeAssignedRole",
"description": "Role assigned to this node in the current cluster layout"
}
]
},
"rustVersion": {
"type": "string",
"description": "rustc version with which this garage release was compiled"
+11 -6
View File
@@ -133,12 +133,17 @@ Use the following command to launch the Garage server:
garage server --single-node --default-bucket
```
The `--single-node` flag instructs Garage to automatically configure a single-node cluster without data replication.
The `--default-bucket` flag instructs Garage to create a default access key and a default bucket using the environment variables we defined above.
Both flags are optional and can be omitted, in which case you will have to follow manual configuration steps described below.
- the `--single-node` flag instructs Garage to automatically configure a
single-node cluster without data replication;
- the `--default-bucket` flag instructs Garage to create a default access key
and a default bucket using the environment variables we defined above (it
implies `--default-access-key`).
**For older versions of Garage (before v2.3.0):** automatic configuration using `--single-node` and `--default-bucket` is not available,
you must follow the manual configuration steps.
> You can refer to the [manual configuration
> steps](#manual-configuration) if:
>
> - you decide to no use these optional flags;
> - you are running an **older version of Garage (before v2.3.0)**.
Alternatively, if you cannot or do not wish to run the Garage binary directly,
you may use Docker to run Garage in a container using the following command:
@@ -292,7 +297,7 @@ An exhaustive list is maintained in the ["Integrations" > "Browsing tools" secti
## Manual configuration
## Manual configuration {#manual-configuration}
This section provides instructions that are equivalent to using the
`--single-node` and `--default-bucket` flags for automatic configuration. If
@@ -175,6 +175,9 @@ they do not exist in the configuration file:
Garage daemon send its logs to `journald` (using the native protocol of `systemd-journald`)
instead of printing to stderr.
- `NO_COLOR` (since `v2.4.0`): set this to `0` or `false` to disable
ANSI color codes in Garage's logs.
The following environment variables can be used to override the corresponding
values in the configuration file:
+5 -6
View File
@@ -8,12 +8,11 @@ which is an alternative storage API designed to help efficiently store
many small values in buckets (in opposition to S3 which is more designed
to store large blobs).
K2V is currently disabled at compile time in all builds, as the
specification is still subject to changes. To build a Garage version with
K2V, the Cargo feature flag `k2v` must be activated. Special builds with
the `k2v` feature flag enabled can be obtained from our download page under
"Extra builds": such builds can be identified easily as their tag name ends
with `-k2v` (example: `v0.7.2-k2v`).
K2V is included in release builds since version 0.8.0. Precompiled builds
of earlier versions including `k2v` can be found in our download page under
"Extra builds": they can be easily identified as their tag name ends with
`-k2v` (example: `v0.7.2-k2v`). Otherwise, when compiling Garage, the Cargo
feature flag `k2v` must be activated.
The specification of the K2V API can be found
[here](https://git.deuxfleurs.fr/Deuxfleurs/garage/src/commit/f8be15c37db857e177d543de7be863692628d567/doc/drafts/k2v-spec.md).
+1 -1
View File
@@ -18,7 +18,7 @@ fi
$GARAGE_BIN -c /tmp/config.1.toml bucket create eprouvette
if [ "$GARAGE_OLDVER" = "v08" ]; then
KEY_INFO=$($GARAGE_BIN -c /tmp/config.1.toml key create opérateur)
KEY_INFO=$($GARAGE_BIN -c /tmp/config.1.toml key new --name opérateur)
ACCESS_KEY=`echo $KEY_INFO|grep -Po 'GK[a-f0-9]+'`
SECRET_KEY=`echo $KEY_INFO|grep -Po 'Secret key: [a-f0-9]+'|grep -Po '[a-f0-9]+$'`
elif [ "$GARAGE_OLDVER" = "v1" ]; then
+2
View File
@@ -21,3 +21,5 @@
.idea/
*.tmproj
.vscode/
# helm-unittest test suites
tests/
+4 -2
View File
@@ -2,7 +2,7 @@ apiVersion: v2
name: garage
description: S3-compatible object store for small self-hosted geo-distributed deployments
type: application
version: 0.9.3
version: 0.9.4
appVersion: "v2.3.0"
home: https://garagehq.deuxfleurs.fr/
icon: https://garagehq.deuxfleurs.fr/images/garage-logo.svg
@@ -15,4 +15,6 @@ keywords:
sources:
- https://git.deuxfleurs.fr/Deuxfleurs/garage.git
maintainers: []
maintainers:
- name: Garage maintainer team
email: garagehq@deuxfleurs.fr
+331
View File
@@ -0,0 +1,331 @@
# An "everything and the kitchen sink" values file for the helm chart: combines many non-default
# settings at once, including examples for the fields that default to empty in
# values.yaml and are therefore hard to guess the expected shape of.
#
# Aside the documentation value, it doubles as an integration-test fixture:
# CI renders and lints the chart with this file (see .woodpecker/debug.yaml)
# to catch feature interactions that per-feature fixtures wouldn't exercise together
# (e.g. both ingresses enabled at once, monitoring + custom service account,
# a DaemonSet-incompatible field set alongside a StatefulSet, ...).
#
# Try it locally with:
# helm template script/helm/garage -f script/helm/garage/complex-values.yaml
# helm lint --strict script/helm/garage -f script/helm/garage/complex-values.yaml
# -- Additional labels to add to all resources created by this chart
commonLabels:
app.kubernetes.io/part-of: storage
team: platform-infrastructure
# Garage configuration. Values under this are written to garage.toml
garage:
# -- sqlite for durability, lmdb for performance
# https://garagehq.deuxfleurs.fr/documentation/reference-manual/configuration/#db_engine
dbEngine: "sqlite"
# -- Here set to 10MiB
# An increase can result in better performance in certain scenarios
# https://garagehq.deuxfleurs.fr/documentation/reference-manual/configuration/#block_size
blockSize: "10485760"
# https://garagehq.deuxfleurs.fr/documentation/reference-manual/configuration/#replication_factor
replicationFactor: "5"
# https://garagehq.deuxfleurs.fr/documentation/reference-manual/configuration/#consistency_mode
consistencyMode: "dangerous"
# -- zstd compression level of stored blocks
# https://garagehq.deuxfleurs.fr/documentation/reference-manual/configuration/#compression_level
compressionLevel: "5"
# -- If this value is set, Garage will automatically take a snapshot of the metadata DB file and save it in the metadata directory.
# https://garagehq.deuxfleurs.fr/documentation/reference-manual/configuration/#metadata_auto_snapshot_interval
metadataAutoSnapshotInterval: "30 days"
rpcBindAddr: "[::]:3901"
# -- If not given, a random secret will be generated and stored in a Secret object
rpcSecret: ""
# -- If you want to provide an rpcSecret within an existing k8s secret,
# specify the secret name here, and store the value under the secret key `rpcSecret`
# ! the default secret will not be created
existingRpcSecret: ""
# -- This is not required if you use the integrated kubernetes discovery. Each
# entry is "<garage_node_id>@<host>:<port>", where <garage_node_id> is the node's public key
# (shown by `garage node id` on that node).
bootstrapPeers:
- "563e1ac825ee3323aa441e72c26d1030d6d4222c43c986812dbf7cd47d18aef@garage-0.garage-headless:3901"
- "86f0f26ae4afbd59aaf9cfb302af3fe0464f2f7b5b21f80f7e6f4e9989b5c1f8@garage-1.garage-headless:3901"
# -- Set to true if you want to use k8s discovery but install the CRDs manually outside
# of the helm chart, for example if you operate at namespace level without cluster resources
kubernetesSkipCrd: true
s3:
api:
bindAddr: "[::]:3900"
region: "garage"
rootDomain: ".s3.garage.tld"
web:
bindAddr: "[::]:3902"
rootDomain: ".web.garage.tld"
index: "index.html"
admin:
apiBindAddr: "[::]:3903"
# -- Additional configuration to append to garage.toml. Use a multi-line string for custom config.
additionalTopLevelConfig: |-
data_fsync = true
# -- if not empty string, allow using an existing ConfigMap for the garage.toml,
# if set, ignores garage.toml
existingConfigMap: ""
# -- String Template for the garage configuration.
# if set, ignores every other garage.* value above and is rendered with `tpl`,
# so it can reference .Values/.Release/.Chart, e.g.:
# garageTomlString: |-
# metadata_dir = "/mnt/meta"
# data_dir = "/mnt/data"
# replication_factor = {{ .Values.garage.replicationFactor }}
# rpc_bind_addr = "{{ .Values.garage.rpcBindAddr }}"
# rpc_secret = "__RPC_SECRET_REPLACE__"
# [kubernetes_discovery]
# namespace = "{{ .Release.Namespace }}"
# service_name = "{{ include "garage.fullname" . }}"
# A rendering-verified version of this example lives in tests/configmap_test.yaml.
garageTomlString: ""
# Data persistence
persistence:
enabled: true
meta:
storageClass: "fast-ssd"
size: 100Mi
# used only for daemon sets
hostPath: /var/lib/garage/meta
data:
storageClass: "standard"
size: 100Mi
# used only for daemon sets
hostPath: /var/lib/garage/data
# Deployment configuration
deployment:
# -- Switchable to DaemonSet
kind: StatefulSet
# -- Number of StatefulSet replicas/garage nodes to start
replicaCount: 3
# -- If using statefulset, allow Parallel or OrderedReady (default)
podManagementPolicy: OrderedReady
image:
# -- default to amd64 docker image
repository: dxflrs/amd64_garage
# -- set the image tag, please prefer using the chart version and not this
# to avoid compatibility issues
tag: ""
pullPolicy: IfNotPresent
initImage:
repository: busybox
tag: stable
pullPolicy: IfNotPresent
# -- set if you need credentials to pull your custom image. Each entry needs a
# `name:` key, matching a Secret of type kubernetes.io/dockerconfigjson.
imagePullSecrets:
- name: my-pull-secret
nameOverride: ""
fullnameOverride: ""
serviceAccount:
# -- Specifies whether a service account should be created
create: true
# -- Annotations to add to the service account. Example below is for AWS IRSA.
annotations:
eks.amazonaws.com/role-arn: "arn:aws:iam::123456789012:role/garage-s3"
# -- The name of the service account to use.
# If not set and create is true, a name is generated using the fullname template
name: ""
# -- additional pod annotations
podAnnotations:
example.com/has-an-annotation: "true"
podSecurityContext:
runAsUser: 1000
runAsGroup: 1000
fsGroup: 1000
fsGroupChangePolicy: "OnRootMismatch"
runAsNonRoot: true
securityContext:
capabilities:
drop:
- ALL
readOnlyRootFilesystem: true
service:
# -- You can rely on any service to expose your cluster
# - ClusterIP (+ Ingress)
# - NodePort (+ Ingress)
# - LoadBalancer
type: ClusterIP
# -- Annotations to add to the service. Example below is for an AWS NLB.
annotations:
service.beta.kubernetes.io/aws-load-balancer-type: "nlb"
s3:
api:
port: 3900
web:
port: 3902
# NOTE: the admin API is excluded for now as it is not consistent across nodes
ingress:
s3:
api:
enabled: true
className: "nginx"
annotations:
cert-manager.io/cluster-issuer: "letsencrypt-prod"
labels: {}
hosts:
# -- garage S3 API endpoint, to be used with awscli for example
- host: "s3.garage.tld"
paths:
- path: /
pathType: Prefix
# -- garage S3 API endpoint, DNS style bucket access
- host: "*.s3.garage.tld"
paths:
- path: /
pathType: Prefix
tls:
- secretName: garage-s3-api-tls
hosts:
- s3.garage.tld
- "*.s3.garage.tld"
web:
enabled: true
className: "nginx"
annotations:
cert-manager.io/cluster-issuer: "letsencrypt-prod"
labels: {}
hosts:
# -- wildcard website access with bucket name prefix
- host: "*.web.garage.tld"
paths:
- path: /
pathType: Prefix
# -- specific bucket access with FQDN bucket
- host: "mywebpage.example.com"
paths:
- path: /
pathType: Prefix
tls:
- secretName: garage-s3-web-tls
hosts:
- "*.web.garage.tld"
- mywebpage.example.com
# The following are indicative for a small-size deployment, for anything serious double them.
resources:
limits:
cpu: 200m
memory: 2048Mi
requests:
cpu: 100m
memory: 1024Mi
# -- Specifies a livenessProbe
livenessProbe:
httpGet:
path: /health
port: 3903 # or the port from garage.admin.apiBindAddr
initialDelaySeconds: 5
periodSeconds: 30
# -- Specifies a readinessProbe
readinessProbe:
httpGet:
path: /health
port: 3903 # or the port from garage.admin.apiBindAddr
initialDelaySeconds: 5
periodSeconds: 30
# -- Example: pin pods to a dedicated storage node pool, paired with the
# toleration below.
nodeSelector:
node-role.kubernetes.io/storage: "true"
tolerations:
- key: "dedicated"
operator: "Equal"
value: "storage"
effect: "NoSchedule"
# -- Example: spread garage replicas across different nodes, since it is a
# geo-distributed store that only helps availability if replicas don't share
# a failure domain or availability zone.
affinity:
podAntiAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
podAffinityTerm:
labelSelector:
matchLabels:
app.kubernetes.io/name: garage
topologyKey: kubernetes.io/hostname
# -- Optional priority class name to assign to the pods.
# See https://kubernetes.io/docs/concepts/scheduling-eviction/pod-priority-preemption/
# This is expected to reference a PriorityClass you define yourself.
priorityClassName: "high-priority-storage"
# -- Extra container env vars. Note this is a [] of {name, value} objects (ie. a pod env stanza)
# GARAGE_ADMIN_TOKEN_FILE below points garage at the token file mounted by
# extraVolumes/extraVolumeMounts, see below.
environment:
- name: RUST_LOG
value: "garage=debug"
- name: GARAGE_ADMIN_TOKEN_FILE
value: /mnt/secrets-store/admin-token
# -- Extra volumes/volumeMounts. Both are []. Example here mounts the admin API
# token from an external secrets manager via the Secrets Store CSI driver
# (https://secrets-store-csi-driver.sigs.k8s.io/) instead of a Secret volume.
# This allows, for example, providing the tokens without creating a Kubernetes
# secret. garage reads the mounted file through GARAGE_ADMIN_TOKEN_FILE above.
extraVolumes:
- name: secrets-store
csi:
driver: secrets-store.csi.k8s.io
readOnly: true
volumeAttributes:
secretProviderClass: garage-admin-token
extraVolumeMounts:
- name: secrets-store
mountPath: /mnt/secrets-store
readOnly: true
monitoring:
metrics:
# -- If true, a service for monitoring is created with a prometheus.io/scrape annotation
enabled: true
serviceMonitor:
# -- If true, a ServiceMonitor CRD is created for a prometheus operator
# https://github.com/coreos/prometheus-operator
enabled: true
path: /metrics
# -- Defaults to the namespace the chart is deployed to; this field is
# templated, so it can also reference .Release.Namespace itself.
namespace: "monitoring"
labels:
release: prometheus
interval: 30s
scheme: http
tlsConfig: {}
scrapeTimeout: 10s
relabelings:
- sourceLabels: ["__meta_kubernetes_pod_node_name"]
targetLabel: node
tracing:
# -- specify a sink endpoint for OpenTelemetry Traces, eg. `http://localhost:4317`
sink: "http://otel-collector.monitoring.svc:4317"
@@ -62,7 +62,9 @@ spec:
{{- end }}
{{- end }}
{{- end }}
{{- if and .Values.ingress.s3.api.enabled .Values.ingress.s3.web.enabled }}
---
{{ end }}
{{- if .Values.ingress.s3.web.enabled -}}
{{- $fullName := include "garage.fullname" . -}}
{{- $svcPort := .Values.service.s3.web.port -}}
@@ -28,11 +28,11 @@ spec:
scheme: {{ .Values.monitoring.metrics.serviceMonitor.scheme }}
{{- with .Values.monitoring.metrics.serviceMonitor.tlsConfig }}
tlsConfig:
{{- toYaml . | nindent 6 }}
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.monitoring.metrics.serviceMonitor.relabelings }}
relabelings:
{{- toYaml . | nindent 6 }}
{{- toYaml . | nindent 8 }}
{{- end }}
jobLabel: "{{ .Release.Name }}"
selector:
@@ -41,4 +41,4 @@ spec:
namespaceSelector:
matchNames:
- {{ .Release.Namespace }}
{{- end }}
{{- end }}
@@ -0,0 +1,31 @@
suite: rbac
templates:
- templates/clusterrole.yaml
tests:
- it: allows managing the garage CRD by default
asserts:
- hasDocuments:
count: 2
- documentIndex: 0
isKind:
of: ClusterRole
- documentIndex: 0
contains:
path: rules[0].resources
content: customresourcedefinitions
- documentIndex: 1
isKind:
of: ClusterRoleBinding
- documentIndex: 1
equal:
path: subjects[0].name
value: RELEASE-NAME-garage
- it: skips the CRD management rule when the CRD is installed manually
set:
garage.kubernetesSkipCrd: true
asserts:
- documentIndex: 0
notContains:
path: rules[0].resources
content: customresourcedefinitions
@@ -0,0 +1,82 @@
# Integration-style suite: renders the whole chart with complex-values.yaml
# (many non-default features combined at once) and checks that they don't
# clobber each other, rather than testing any single feature in isolation
# (that's what the other tests/*_test.yaml suites are for).
suite: complex-values integration
templates:
- templates/workload.yaml
- templates/service.yaml
- templates/service-headless.yaml
- templates/ingress.yaml
- templates/servicemonitor.yaml
- templates/serviceaccount.yaml
- templates/configmap.yaml
- templates/clusterrole.yaml
tests:
- it: renders a self-consistent deployment with every optional feature enabled
values:
- ../complex-values.yaml
asserts:
- template: templates/workload.yaml
isKind:
of: StatefulSet
- template: templates/workload.yaml
equal:
path: metadata.labels.team
value: platform-infrastructure
- template: templates/workload.yaml
equal:
path: spec.template.spec.containers[0].env[0].name
value: RUST_LOG
- template: templates/workload.yaml
contains:
path: spec.template.spec.volumes
content:
name: secrets-store
csi:
driver: secrets-store.csi.k8s.io
readOnly: true
volumeAttributes:
secretProviderClass: garage-admin-token
- template: templates/workload.yaml
equal:
path: spec.template.spec.containers[0].env[1].name
value: GARAGE_ADMIN_TOKEN_FILE
- template: templates/workload.yaml
equal:
path: spec.volumeClaimTemplates[0].spec.storageClassName
value: fast-ssd
- template: templates/workload.yaml
contains:
path: spec.template.spec.imagePullSecrets
content:
name: my-pull-secret
- template: templates/service.yaml
hasDocuments:
count: 2 # main service + metrics service, since monitoring.metrics.enabled is true here
- template: templates/service-headless.yaml
hasDocuments:
count: 1 # StatefulSet still gets a headless service
- template: templates/ingress.yaml
hasDocuments:
count: 2 # both s3 api and s3 web ingresses enabled together
- template: templates/servicemonitor.yaml
hasDocuments:
count: 1
- template: templates/servicemonitor.yaml
equal:
path: spec.endpoints[0].relabelings[0].targetLabel
value: node
- template: templates/serviceaccount.yaml
equal:
path: metadata.annotations["eks.amazonaws.com/role-arn"]
value: "arn:aws:iam::123456789012:role/garage-s3"
- template: templates/configmap.yaml
matchRegex:
path: data["garage.toml"]
pattern: 'data_fsync = true'
- template: templates/clusterrole.yaml
documentIndex: 0
notContains:
path: rules[0].resources
content: customresourcedefinitions # garage.kubernetesSkipCrd is true here
@@ -0,0 +1,136 @@
suite: configmap
templates:
- templates/configmap.yaml
tests:
- it: renders garage.toml with the default configuration
asserts:
- hasDocuments:
count: 1
- isKind:
of: ConfigMap
- equal:
path: metadata.name
value: RELEASE-NAME-garage-config
- matchRegex:
path: data["garage.toml"]
pattern: 'metadata_dir = "/mnt/meta"'
- matchRegex:
path: data["garage.toml"]
pattern: 'data_dir = "/mnt/data"'
- matchRegex:
path: data["garage.toml"]
pattern: 'db_engine = "lmdb"'
- matchRegex:
path: data["garage.toml"]
pattern: 'block_size = "1048576"'
- matchRegex:
path: data["garage.toml"]
pattern: 'replication_factor = 3'
- matchRegex:
path: data["garage.toml"]
pattern: 'consistency_mode = "consistent"'
- matchRegex:
path: data["garage.toml"]
pattern: 'compression_level = 1'
- matchRegex:
path: data["garage.toml"]
pattern: 'rpc_bind_addr = "\[::\]:3901"'
- matchRegex:
path: data["garage.toml"]
pattern: 'rpc_secret = "__RPC_SECRET_REPLACE__"'
- matchRegex:
path: data["garage.toml"]
pattern: '(?s)\[kubernetes_discovery\]\s*namespace = "NAMESPACE"\s*service_name = "RELEASE-NAME-garage"\s*skip_crd = false'
- matchRegex:
path: data["garage.toml"]
pattern: '(?s)\[s3_api\]\s*s3_region = "garage"\s*api_bind_addr = "\[::\]:3900"\s*root_domain = "\.s3\.garage\.tld"'
- matchRegex:
path: data["garage.toml"]
pattern: '(?s)\[s3_web\]\s*bind_addr = "\[::\]:3902"\s*root_domain = "\.web\.garage\.tld"\s*index = "index.html"'
- matchRegex:
path: data["garage.toml"]
pattern: '(?s)\[admin\]\s*api_bind_addr = "\[::\]:3903"'
- notMatchRegex:
path: data["garage.toml"]
pattern: 'metadata_auto_snapshot_interval'
- notMatchRegex:
path: data["garage.toml"]
pattern: 'trace_sink'
- it: reflects custom garage settings, bootstrap peers and additional config
set:
garage.dbEngine: sqlite
garage.blockSize: "2097152"
garage.replicationFactor: "5"
garage.consistencyMode: degraded
garage.compressionLevel: "3"
garage.metadataAutoSnapshotInterval: 6h
garage.bootstrapPeers:
- abc@peer1:3901
- def@peer2:3901
garage.additionalTopLevelConfig: "data_fsync = true"
monitoring.tracing.sink: http://otel:4317
asserts:
- matchRegex:
path: data["garage.toml"]
pattern: 'db_engine = "sqlite"'
- matchRegex:
path: data["garage.toml"]
pattern: 'block_size = "2097152"'
- matchRegex:
path: data["garage.toml"]
pattern: 'replication_factor = 5'
- matchRegex:
path: data["garage.toml"]
pattern: 'consistency_mode = "degraded"'
- matchRegex:
path: data["garage.toml"]
pattern: 'compression_level = 3'
- matchRegex:
path: data["garage.toml"]
pattern: 'metadata_auto_snapshot_interval = "6h"'
- matchRegex:
path: data["garage.toml"]
pattern: 'bootstrap_peers = \["abc@peer1:3901"\s*, "def@peer2:3901"'
- matchRegex:
path: data["garage.toml"]
pattern: 'data_fsync = true'
- matchRegex:
path: data["garage.toml"]
pattern: 'trace_sink = "http://otel:4317"'
- it: uses garageTomlString verbatim when set, ignoring the structured values
set:
garage.garageTomlString: |-
metadata_dir = "/custom/meta"
replication_factor = 1
garage.dbEngine: sqlite
asserts:
- equal:
path: data["garage.toml"]
value: |-
metadata_dir = "/custom/meta"
replication_factor = 1
- notMatchRegex:
path: data["garage.toml"]
pattern: 'db_engine'
- it: templates garageTomlString against the release and values context
set:
garage.garageTomlString: |-
# namespace: {{ .Release.Namespace }}
replication_factor = {{ .Values.garage.replicationFactor }}
garage.replicationFactor: "7"
asserts:
- equal:
path: data["garage.toml"]
value: |-
# namespace: NAMESPACE
replication_factor = 7
- it: does not render a ConfigMap when an existing one is referenced
set:
garage.existingConfigMap: my-external-cm
asserts:
- hasDocuments:
count: 0
@@ -0,0 +1,95 @@
suite: ingress
templates:
- templates/ingress.yaml
tests:
- it: renders no ingress by default
asserts:
- hasDocuments:
count: 0
- it: renders api and web ingresses with tls when enabled
values:
- ./values/ingress.yaml
asserts:
- hasDocuments:
count: 2
- isKind:
of: Ingress
- documentIndex: 0
equal:
path: metadata.name
value: RELEASE-NAME-garage-s3-api
- documentIndex: 0
equal:
path: spec.ingressClassName
value: nginx
- documentIndex: 0
equal:
path: spec.rules[0].host
value: s3.example.com
- documentIndex: 0
equal:
path: spec.tls[0].secretName
value: garage-s3-api-tls
- documentIndex: 1
equal:
path: metadata.name
value: RELEASE-NAME-garage-s3-web
- documentIndex: 1
equal:
path: spec.rules[0].host
value: "*.web.example.com"
- documentIndex: 1
equal:
path: spec.tls[0].secretName
value: garage-s3-web-tls
- it: can enable only the s3 api ingress
set:
ingress.s3.api.enabled: true
ingress.s3.api.hosts[0].host: s3.example.com
ingress.s3.api.hosts[0].paths[0].path: /
ingress.s3.api.hosts[0].paths[0].pathType: Prefix
asserts:
- hasDocuments:
count: 1
- equal:
path: metadata.name
value: RELEASE-NAME-garage-s3-api
- it: omits ingressClassName and tls when neither is configured
set:
ingress.s3.api.enabled: true
ingress.s3.api.hosts[0].host: s3.example.com
ingress.s3.api.hosts[0].paths[0].path: /
ingress.s3.api.hosts[0].paths[0].pathType: Prefix
asserts:
- isNull:
path: spec.ingressClassName
- isNull:
path: spec.tls
- it: renders multiple hosts on the same ingress
set:
ingress.s3.api.enabled: true
ingress.s3.api.hosts:
- host: s3.example.com
paths:
- path: /
pathType: Prefix
- host: s3-alt.example.com
paths:
- path: /
pathType: Prefix
asserts:
- hasDocuments:
count: 1
- lengthEqual:
path: spec.rules
count: 2
- equal:
path: spec.rules[0].host
value: s3.example.com
- equal:
path: spec.rules[1].host
value: s3-alt.example.com
+56
View File
@@ -0,0 +1,56 @@
suite: naming and common labels
templates:
- templates/workload.yaml
- templates/configmap.yaml
tests:
- it: applies commonLabels alongside the default chart labels
template: templates/workload.yaml
set:
commonLabels:
team: storage
asserts:
- equal:
path: metadata.labels.team
value: storage
- equal:
path: metadata.labels["app.kubernetes.io/managed-by"]
value: Helm
- it: uses fullnameOverride verbatim for resource names
template: templates/workload.yaml
set:
fullnameOverride: my-garage-cluster
asserts:
- equal:
path: metadata.name
value: my-garage-cluster
- equal:
path: spec.serviceName
value: my-garage-cluster-headless
- it: does not double-prefix when the release name already contains the chart name
template: templates/workload.yaml
release:
name: garage
asserts:
- equal:
path: metadata.name
value: garage
- it: prefixes the release name with the chart name otherwise
template: templates/workload.yaml
release:
name: prod
asserts:
- equal:
path: metadata.name
value: prod-garage
- it: truncates an overly long fullname to 63 characters and trims a trailing dash
template: templates/workload.yaml
set:
fullnameOverride: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-suffix-that-will-be-cut-off
asserts:
- equal:
path: metadata.name
value: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+33
View File
@@ -0,0 +1,33 @@
suite: rpc secret
templates:
- templates/secret.yaml
tests:
- it: generates a Secret holding the rpc secret by default
asserts:
- hasDocuments:
count: 1
- isKind:
of: Secret
- equal:
path: metadata.name
value: RELEASE-NAME-garage-rpc-secret
- equal:
path: type
value: Opaque
- isNotNull:
path: data.rpcSecret
- it: does not render a Secret when an existing one is referenced
values:
- ./values/existing-secret.yaml
asserts:
- hasDocuments:
count: 0
- it: base64-encodes an explicitly provided rpc secret
set:
garage.rpcSecret: my-plain-secret
asserts:
- equal:
path: data.rpcSecret
value: bXktcGxhaW4tc2VjcmV0
@@ -0,0 +1,26 @@
suite: headless service
templates:
- templates/service-headless.yaml
tests:
- it: creates a headless service for a StatefulSet by default
asserts:
- hasDocuments:
count: 1
- isKind:
of: Service
- equal:
path: metadata.name
value: RELEASE-NAME-garage-headless
- equal:
path: spec.clusterIP
value: None
- equal:
path: spec.type
value: ClusterIP
- it: does not create a headless service for a DaemonSet
values:
- ./values/daemonset.yaml
asserts:
- hasDocuments:
count: 0
@@ -0,0 +1,61 @@
suite: service
templates:
- templates/service.yaml
tests:
- it: creates a ClusterIP service with s3-api and s3-web ports by default
asserts:
- hasDocuments:
count: 1
- isKind:
of: Service
- equal:
path: spec.type
value: ClusterIP
- equal:
path: spec.ports[0].name
value: s3-api
- equal:
path: spec.ports[0].port
value: 3900
- equal:
path: spec.ports[1].name
value: s3-web
- equal:
path: spec.ports[1].port
value: 3902
- it: honors a custom service type and port
set:
service.type: LoadBalancer
service.s3.api.port: 9000
asserts:
- equal:
path: spec.type
value: LoadBalancer
- equal:
path: spec.ports[0].port
value: 9000
- it: does not create a metrics service by default
asserts:
- hasDocuments:
count: 1
- it: adds a headless metrics service when monitoring is enabled
values:
- ./values/monitoring.yaml
asserts:
- hasDocuments:
count: 2
- documentIndex: 1
equal:
path: metadata.name
value: RELEASE-NAME-garage-metrics
- documentIndex: 1
equal:
path: spec.clusterIP
value: None
- documentIndex: 1
equal:
path: metadata.annotations["prometheus.io/scrape"]
value: "true"
@@ -0,0 +1,28 @@
suite: service account
templates:
- templates/serviceaccount.yaml
tests:
- it: creates a ServiceAccount by default
asserts:
- hasDocuments:
count: 1
- isKind:
of: ServiceAccount
- equal:
path: metadata.name
value: RELEASE-NAME-garage
- it: does not create a ServiceAccount when disabled
values:
- ./values/minimal.yaml
asserts:
- hasDocuments:
count: 0
- it: honors a custom service account name
set:
serviceAccount.name: my-garage-sa
asserts:
- equal:
path: metadata.name
value: my-garage-sa
@@ -0,0 +1,65 @@
suite: service monitor
templates:
- templates/servicemonitor.yaml
tests:
- it: renders no ServiceMonitor by default
asserts:
- hasDocuments:
count: 0
- it: renders no ServiceMonitor when only metrics are enabled
set:
monitoring.metrics.enabled: true
asserts:
- hasDocuments:
count: 0
- it: renders a ServiceMonitor when explicitly enabled
values:
- ./values/monitoring.yaml
asserts:
- hasDocuments:
count: 1
- isKind:
of: ServiceMonitor
- equal:
path: metadata.name
value: RELEASE-NAME-garage
- equal:
path: metadata.namespace
value: NAMESPACE
- equal:
path: spec.endpoints[0].interval
value: 30s
- it: templates a custom namespace against the release context
values:
- ./values/monitoring.yaml
set:
monitoring.metrics.serviceMonitor.namespace: "{{ .Release.Namespace }}-monitoring"
asserts:
- equal:
path: metadata.namespace
value: NAMESPACE-monitoring
- it: applies custom labels, tlsConfig and relabelings
values:
- ./values/monitoring.yaml
set:
monitoring.metrics.serviceMonitor.labels:
team: storage
monitoring.metrics.serviceMonitor.tlsConfig:
insecureSkipVerify: true
monitoring.metrics.serviceMonitor.relabelings:
- sourceLabels: ["__meta_kubernetes_pod_name"]
targetLabel: pod
asserts:
- equal:
path: metadata.labels.team
value: storage
- equal:
path: spec.endpoints[0].tlsConfig.insecureSkipVerify
value: true
- equal:
path: spec.endpoints[0].relabelings[0].targetLabel
value: pod
@@ -0,0 +1,10 @@
# Run garage as a DaemonSet (one pod per node) instead of the default StatefulSet,
# using hostPath volumes for meta/data persistence.
deployment:
kind: DaemonSet
persistence:
enabled: true
meta:
hostPath: /var/lib/garage/meta
data:
hostPath: /var/lib/garage/data
@@ -0,0 +1,5 @@
# Use a pre-existing Kubernetes Secret for the RPC secret instead of letting
# the chart generate/manage one.
garage:
rpcSecret: ""
existingRpcSecret: "garage-rpc-secret-external"
@@ -0,0 +1,27 @@
# Expose both the S3 API and website endpoints through Ingress, with TLS.
ingress:
s3:
api:
enabled: true
className: "nginx"
hosts:
- host: "s3.example.com"
paths:
- path: /
pathType: Prefix
tls:
- secretName: garage-s3-api-tls
hosts:
- s3.example.com
web:
enabled: true
className: "nginx"
hosts:
- host: "*.web.example.com"
paths:
- path: /
pathType: Prefix
tls:
- secretName: garage-s3-web-tls
hosts:
- "*.web.example.com"
@@ -0,0 +1,8 @@
# Minimal single-node deployment without persistent storage or a dedicated
# service account, e.g. for local testing.
deployment:
replicaCount: 1
persistence:
enabled: false
serviceAccount:
create: false
@@ -0,0 +1,7 @@
# Enable Prometheus metrics scraping and a ServiceMonitor for the prometheus-operator.
monitoring:
metrics:
enabled: true
serviceMonitor:
enabled: true
interval: 30s
+187
View File
@@ -0,0 +1,187 @@
suite: workload (StatefulSet/DaemonSet)
templates:
- templates/workload.yaml
- templates/configmap.yaml
tests:
- it: defaults to a StatefulSet with 3 replicas and 2 volumes
template: templates/workload.yaml
asserts:
- isKind:
of: StatefulSet
- equal:
path: spec.replicas
value: 3
- equal:
path: spec.podManagementPolicy
value: OrderedReady
- equal:
path: spec.template.spec.volumes[1].name
value: etc
- lengthEqual:
path: spec.template.spec.volumes
count: 2
- isNotNull:
path: spec.volumeClaimTemplates
- it: uses a StatefulSet with PVC-backed volumeClaimTemplates by default
template: templates/workload.yaml
asserts:
- isKind:
of: StatefulSet
- isNotNull:
path: spec.volumeClaimTemplates
- equal:
path: spec.volumeClaimTemplates[0].spec.resources.requests.storage
value: 100Mi
- it: switches to a DaemonSet with hostPath volumes when requested
template: templates/workload.yaml
values:
- ./values/daemonset.yaml
asserts:
- isKind:
of: DaemonSet
- isNull:
path: spec.replicas
- isNull:
path: spec.volumeClaimTemplates
- contains:
path: spec.template.spec.volumes
content:
name: meta
hostPath:
path: /var/lib/garage/meta
type: DirectoryOrCreate
- contains:
path: spec.template.spec.volumes
content:
name: data
hostPath:
path: /var/lib/garage/data
type: DirectoryOrCreate
- it: renders emptyDir volumes when persistence is disabled
template: templates/workload.yaml
values:
- ./values/minimal.yaml
asserts:
- contains:
path: spec.template.spec.volumes
content:
name: meta
emptyDir: {}
- contains:
path: spec.template.spec.volumes
content:
name: data
emptyDir: {}
- isNull:
path: spec.volumeClaimTemplates
- it: honors a custom replicaCount
template: templates/workload.yaml
set:
deployment.replicaCount: 5
asserts:
- equal:
path: spec.replicas
value: 5
- it: points the init container at the configured rpc secret
template: templates/workload.yaml
asserts:
- equal:
path: spec.template.spec.initContainers[0].env[0].valueFrom.secretKeyRef.name
value: RELEASE-NAME-garage-rpc-secret
- it: points the init container at an existing rpc secret when configured
template: templates/workload.yaml
values:
- ./values/existing-secret.yaml
asserts:
- equal:
path: spec.template.spec.initContainers[0].env[0].valueFrom.secretKeyRef.name
value: garage-rpc-secret-external
- it: sets the container image from repository and tag
template: templates/workload.yaml
set:
image.repository: dxflrs/amd64_garage
image.tag: v1.2.3
asserts:
- equal:
path: spec.template.spec.containers[0].image
value: dxflrs/amd64_garage:v1.2.3
- it: falls back to the chart appVersion when no image tag is set
template: templates/workload.yaml
asserts:
- matchRegex:
path: spec.template.spec.containers[0].image
pattern: ^dxflrs/amd64_garage:v
- it: omits storageClassName from volumeClaimTemplates by default
template: templates/workload.yaml
asserts:
- isNull:
path: spec.volumeClaimTemplates[0].spec.storageClassName
- isNull:
path: spec.volumeClaimTemplates[1].spec.storageClassName
- it: sets storageClassName in volumeClaimTemplates when configured
template: templates/workload.yaml
set:
persistence.meta.storageClass: fast-storage
persistence.data.storageClass: slow-storage
asserts:
- equal:
path: spec.volumeClaimTemplates[0].spec.storageClassName
value: fast-storage
- equal:
path: spec.volumeClaimTemplates[1].spec.storageClassName
value: slow-storage
- it: renders emptyDir volumes for a DaemonSet when persistence is disabled
template: templates/workload.yaml
set:
deployment.kind: DaemonSet
persistence.enabled: false
asserts:
- contains:
path: spec.template.spec.volumes
content:
name: meta
emptyDir: {}
- contains:
path: spec.template.spec.volumes
content:
name: data
emptyDir: {}
- it: mounts the existing ConfigMap volume when configured
template: templates/workload.yaml
set:
garage.existingConfigMap: my-external-cm
asserts:
- equal:
path: spec.template.spec.volumes[0].configMap.name
value: my-external-cm
- it: uses a custom service account name without creating one when disabled
template: templates/workload.yaml
set:
serviceAccount.create: false
serviceAccount.name: my-external-sa
asserts:
- equal:
path: spec.template.spec.serviceAccountName
value: my-external-sa
- it: falls back to the default service account when disabled without a custom name
template: templates/workload.yaml
set:
serviceAccount.create: false
asserts:
- equal:
path: spec.template.spec.serviceAccountName
value: default
+4
View File
@@ -246,10 +246,14 @@ affinity: {}
# See https://kubernetes.io/docs/concepts/scheduling-eviction/pod-priority-preemption/
priorityClassName: ""
# -- Extra container env vars, as a list of {name, value} objects (same shape
# as a Pod container's env)
environment: {}
# -- Extra volumes, as a list of volume objects (same shape as a PodSpec's volumes)
extraVolumes: {}
# -- Extra volume mounts, as a list of mount objects (same shape as a container's volumeMounts)
extraVolumeMounts: {}
monitoring:
+6 -36
View File
@@ -688,26 +688,11 @@ pub struct ClusterLayoutSkipDeadNodesResponse {
// ---- ListKeys ----
#[derive(Debug, Clone, Serialize, Deserialize, Default, IntoParams)]
#[into_params(parameter_in = Query)]
pub struct ListKeysRequest {
/// Returned detailed informations in the same format as GetKeyInfo for each bucket
#[serde(default)]
pub details: bool,
/// Key ID of the first key to return
#[serde(default)]
pub offset: Option<String>,
/// Maximum number of keys to return in a single call
#[serde(default)]
pub limit: Option<usize>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ListKeysRequest;
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
#[serde(untagged)]
pub enum ListKeysResponse {
WithoutDetails(Vec<ListKeysResponseItem>),
WithDetails(Vec<GetKeyInfoResponse>),
}
pub struct ListKeysResponse(pub Vec<ListKeysResponseItem>);
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "camelCase")]
@@ -845,26 +830,11 @@ pub struct DeleteKeyResponse;
// ---- ListBuckets ----
#[derive(Debug, Clone, Serialize, Deserialize, Default, IntoParams)]
#[into_params(parameter_in = Query)]
pub struct ListBucketsRequest {
/// Returned detailed informations in the same format as GetBucketInfo for each bucket
#[serde(default)]
pub details: bool,
/// Bucket ID of the first bucket to return
#[serde(default)]
pub offset: Option<String>,
/// Maximum number of buckets to return in a single call
#[serde(default)]
pub limit: Option<usize>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ListBucketsRequest;
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
#[serde(untagged)]
pub enum ListBucketsResponse {
WithoutDetails(Vec<ListBucketsResponseItem>),
WithDetails(Vec<GetBucketInfoResponse>),
}
pub struct ListBucketsResponse(pub Vec<ListBucketsResponseItem>);
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "camelCase")]
+31 -55
View File
@@ -3,7 +3,6 @@ use std::sync::Arc;
use std::time::Duration;
use chrono::DateTime;
use futures::StreamExt;
use garage_util::crdt::*;
use garage_util::data::*;
@@ -33,70 +32,47 @@ impl RequestHandler for ListBucketsRequest {
garage: &Arc<Garage>,
_admin: &Admin,
) -> Result<ListBucketsResponse, Error> {
let limit = self
.limit
.unwrap_or_else(|| if self.details { 1000 } else { 10_000 });
let offset = match self.offset {
Some(id) => Some(parse_bucket_id(&id)?),
None => None,
};
let buckets = garage
.bucket_table
.get_range(
&EmptyKey,
offset,
None,
Some(DeletedFilter::NotDeleted),
limit,
1_000_000,
EnumerationOrder::Forward,
)
.await?;
if self.details {
let mut stream = buckets
.into_iter()
.map(|b| bucket_info_results(garage, b.id))
.collect::<futures::stream::FuturesOrdered<_>>();
let res = buckets
.into_iter()
.map(|b| {
let state = b.state.as_option().unwrap();
ListBucketsResponseItem {
id: hex::encode(b.id),
created: DateTime::from_timestamp_millis(state.creation_date as i64)
.expect("invalid timestamp stored in db"),
global_aliases: state
.aliases
.items()
.iter()
.filter(|(_, _, a)| *a)
.map(|(n, _, _)| n.to_string())
.collect::<Vec<_>>(),
local_aliases: state
.local_aliases
.items()
.iter()
.filter(|(_, _, a)| *a)
.map(|((k, n), _, _)| BucketLocalAlias {
access_key_id: k.to_string(),
alias: n.to_string(),
})
.collect::<Vec<_>>(),
}
})
.collect::<Vec<_>>();
let mut res = vec![];
while let Some(next) = stream.next().await {
res.push(next?);
}
Ok(ListBucketsResponse::WithDetails(res))
} else {
let res = buckets
.into_iter()
.map(|b| {
let state = b.state.as_option().unwrap();
ListBucketsResponseItem {
id: hex::encode(b.id),
created: DateTime::from_timestamp_millis(state.creation_date as i64)
.expect("invalid timestamp stored in db"),
global_aliases: state
.aliases
.items()
.iter()
.filter(|(_, _, a)| *a)
.map(|(n, _, _)| n.to_string())
.collect::<Vec<_>>(),
local_aliases: state
.local_aliases
.items()
.iter()
.filter(|(_, _, a)| *a)
.map(|((k, n), _, _)| BucketLocalAlias {
access_key_id: k.to_string(),
alias: n.to_string(),
})
.collect::<Vec<_>>(),
}
})
.collect::<Vec<_>>();
Ok(ListBucketsResponse::WithoutDetails(res))
}
Ok(ListBucketsResponse(res))
}
}
+23 -44
View File
@@ -2,7 +2,6 @@ use std::collections::HashMap;
use std::sync::Arc;
use chrono::DateTime;
use futures::StreamExt;
use garage_table::*;
use garage_util::time::now_msec;
@@ -21,57 +20,37 @@ impl RequestHandler for ListKeysRequest {
async fn handle(self, garage: &Arc<Garage>, _admin: &Admin) -> Result<ListKeysResponse, Error> {
let now = now_msec();
let limit = self
.limit
.unwrap_or_else(|| if self.details { 1000 } else { 10_000 });
let keys = garage
let res = garage
.key_table
.get_range(
&EmptyKey,
self.offset,
None,
Some(KeyFilter::Deleted(DeletedFilter::NotDeleted)),
limit,
10000,
EnumerationOrder::Forward,
)
.await?;
.await?
.iter()
.map(|k| {
let p = k.params().unwrap();
if self.details {
let mut stream = keys
.into_iter()
.map(|k| key_info_results(garage, k, false))
.collect::<futures::stream::FuturesOrdered<_>>();
ListKeysResponseItem {
id: k.key_id.to_string(),
name: p.name.get().clone(),
created: p.created.map(|x| {
DateTime::from_timestamp_millis(x as i64)
.expect("invalid timestamp stored in db")
}),
expiration: p.expiration.get().inner().map(|x| {
DateTime::from_timestamp_millis(x.0 as i64)
.expect("invalid timestamp stored in db")
}),
expired: p.is_expired(now),
}
})
.collect::<Vec<_>>();
let mut res = vec![];
while let Some(next) = stream.next().await {
res.push(next?);
}
Ok(ListKeysResponse::WithDetails(res))
} else {
let res = keys
.iter()
.map(|k| {
let p = k.params().unwrap();
ListKeysResponseItem {
id: k.key_id.to_string(),
name: p.name.get().clone(),
created: p.created.map(|x| {
DateTime::from_timestamp_millis(x as i64)
.expect("invalid timestamp stored in db")
}),
expiration: p.expiration.get().inner().map(|x| {
DateTime::from_timestamp_millis(x.0 as i64)
.expect("invalid timestamp stored in db")
}),
expired: p.is_expired(now),
}
})
.collect::<Vec<_>>();
Ok(ListKeysResponse::WithoutDetails(res))
}
Ok(ListKeysResponse(res))
}
}
+1 -3
View File
@@ -364,7 +364,6 @@ fn ClusterLayoutSkipDeadNodes() {}
path = "/v2/ListKeys",
tag = "Access key",
description = "Returns all API access keys in the cluster.",
params(ListKeysRequest),
responses(
(status = 200, description = "Returns the key identifier (aka `AWS_ACCESS_KEY_ID`) and its associated, human friendly, name if any (otherwise return an empty string)", body = ListKeysResponse),
(status = 500, description = "Internal server error")
@@ -454,7 +453,6 @@ fn DeleteKey() {}
path = "/v2/ListBuckets",
tag = "Bucket",
description = "List all the buckets on the cluster with their UUID and their global and local aliases.",
params(ListBucketsRequest),
responses(
(status = 200, description = "Returns the UUID of all the buckets and all their aliases", body = ListBucketsResponse),
(status = 500, description = "Internal server error")
@@ -878,7 +876,7 @@ impl Modify for SecurityAddon {
#[derive(OpenApi)]
#[openapi(
info(
version = "v2.4.0",
version = "v2.3.0",
title = "Garage administration API",
description = "Administrate your Garage cluster programmatically, including status, layout, keys, buckets, and maintenance tasks.
+5 -10
View File
@@ -55,10 +55,10 @@ impl AdminApiRequest {
POST CreateKey (body),
POST ImportKey (body),
POST DeleteKey (query::id),
GET ListKeys (parse_default(false)::details, query_opt::offset, opt_parse::limit),
GET ListKeys (),
// Bucket endpoints
GET GetBucketInfo (query_opt::id, query_opt::global_alias, query_opt::search),
GET ListBuckets (parse_default(false)::details, query_opt::offset, opt_parse::limit),
GET ListBuckets (),
POST CreateBucket (body),
POST DeleteBucket (query::id),
POST UpdateBucket (body_field, query::id),
@@ -129,7 +129,7 @@ impl AdminApiRequest {
)),
// Keys
Endpoint::ListKeys => Ok(AdminApiRequest::ListKeys(ListKeysRequest::default())),
Endpoint::ListKeys => Ok(AdminApiRequest::ListKeys(ListKeysRequest)),
Endpoint::GetKeyInfo {
id,
search,
@@ -161,9 +161,7 @@ impl AdminApiRequest {
// Endpoint::DeleteKey { id } => Ok(AdminApiRequest::DeleteKey(DeleteKeyRequest { id })),
// Buckets
Endpoint::ListBuckets => {
Ok(AdminApiRequest::ListBuckets(ListBucketsRequest::default()))
}
Endpoint::ListBuckets => Ok(AdminApiRequest::ListBuckets(ListBucketsRequest)),
Endpoint::GetBucketInfo { id, global_alias } => {
Ok(AdminApiRequest::GetBucketInfo(GetBucketInfoRequest {
id,
@@ -273,9 +271,6 @@ generateQueryParameters! {
"accessKeyId" => access_key_id,
"showSecretKey" => show_secret_key,
"bucketId" => bucket_id,
"key" => key,
"details" => details,
"offset" => offset,
"limit" => limit
"key" => key
]
}
+3 -10
View File
@@ -31,19 +31,12 @@ impl Cli {
}
pub async fn cmd_list_buckets(&self) -> Result<(), Error> {
let mut buckets = match self.api_request(ListBucketsRequest::default()).await? {
ListBucketsResponse::WithoutDetails(list) => list,
_ => {
return Err(Error::Message(
"Unexpected ListBuckets response format".into(),
))
}
};
let mut buckets = self.api_request(ListBucketsRequest).await?;
buckets.sort_by_key(|x| x.created);
buckets.0.sort_by_key(|x| x.created);
let mut table = vec!["ID\tCreated\tGlobal aliases\tLocal aliases".to_string()];
for bucket in buckets.iter() {
for bucket in buckets.0.iter() {
table.push(format!(
"{:.16}\t{}\t{}\t{}",
bucket.id,
+4 -10
View File
@@ -28,15 +28,12 @@ impl Cli {
}
pub async fn cmd_list_keys(&self) -> Result<(), Error> {
let mut keys = match self.api_request(ListKeysRequest::default()).await? {
ListKeysResponse::WithoutDetails(list) => list,
_ => return Err(Error::Message("Unexpected ListKeys response format".into())),
};
let mut keys = self.api_request(ListKeysRequest).await?;
keys.sort_by_key(|x| x.created);
keys.0.sort_by_key(|x| x.created);
let mut table = vec!["ID\tCreated\tName\tExpiration".to_string()];
for key in keys.iter() {
for key in keys.0.iter() {
let exp = if key.expired {
Cow::from("expired")
} else {
@@ -246,10 +243,7 @@ impl Cli {
}
pub async fn cmd_delete_expired_keys(&self, yes: bool) -> Result<(), Error> {
let mut list = match self.api_request(ListKeysRequest::default()).await? {
ListKeysResponse::WithoutDetails(list) => list,
_ => return Err(Error::Message("Unexpected ListKeys response format".into())),
};
let mut list = self.api_request(ListKeysRequest).await?.0;
list.retain(|key| key.expired);
+8 -21
View File
@@ -110,30 +110,17 @@ impl Cli {
Ok(resp.success.into_iter().next().unwrap().1)
}
pub async fn cmd_json_api(
&self,
endpoint: String,
payload: Option<String>,
) -> Result<(), Error> {
let request: AdminApiRequest = if let Some(payload) = payload {
let payload: serde_json::Value = if payload == "-" {
serde_json::from_reader(&std::io::stdin())?
} else {
serde_json::from_str(&payload)?
};
serde_json::from_value(serde_json::json!({
endpoint.clone(): payload,
}))?
pub async fn cmd_json_api(&self, endpoint: String, payload: String) -> Result<(), Error> {
let payload: serde_json::Value = if payload == "-" {
serde_json::from_reader(&std::io::stdin())?
} else {
serde_json::from_value(serde_json::json!({
endpoint.clone(): null,
}))
.or(serde_json::from_value(serde_json::json!({
endpoint.clone(): {},
})))?
serde_json::from_str(&payload)?
};
let request: AdminApiRequest = serde_json::from_value(serde_json::json!({
endpoint.clone(): payload,
}))?;
let resp = match self
.proxy_rpc_endpoint
.call(&self.rpc_host, ProxyRpc::Proxy(request), PRIO_NORMAL)
+2 -1
View File
@@ -78,7 +78,8 @@ pub enum Command {
/// The admin API endpoint to invoke, e.g. `GetClusterStatus`
endpoint: String,
/// The JSON payload, or `-` to read from `stdin`
payload: Option<String>,
#[structopt(default_value = "null")]
payload: String,
},
/// Generate completions for a shell
+5
View File
@@ -276,6 +276,11 @@ fn init_logging(opt: &Opt) {
tracing_subscriber::fmt()
.with_writer(std::io::stderr)
.with_env_filter(env_filter)
.with_ansi(
std::env::var("NO_COLOR")
.map(|x| x != "0" && !x.eq_ignore_ascii_case("false"))
.unwrap_or(true),
)
.init();
}
+14
View File
@@ -152,6 +152,14 @@ pub async fn run_server(
}
}
// Deregister from Consul (if enabled) in the background, in parallel with the
// rest of the shutdown sequence, so that it doesn't add to shutdown latency.
#[cfg(feature = "consul-discovery")]
let deregister_consul_task = tokio::spawn({
let system = garage.system.clone();
async move { system.deregister_from_discovery().await }
});
// Remove RPC handlers for system to break reference cycles
info!("Deregistering RPC handlers for shutdown...");
garage.system.netapp.drop_all_handlers();
@@ -168,6 +176,12 @@ pub async fn run_server(
// Await for all background tasks to end
await_background_done.await?;
// Await for Consul deregistration to end, if it hasn't already
#[cfg(feature = "consul-discovery")]
if let Err(e) = deregister_consul_task.await {
error!("Error while joining Consul deregistration task: {}", e);
}
info!("Cleaning up...");
Ok(())
+30
View File
@@ -183,6 +183,36 @@ impl ConsulDiscovery {
}
// ---- PUBLISHING TO CONSUL CATALOG ----
#[cfg(feature = "consul-discovery")]
pub async fn deregister_consul_service(&self, node_id: NodeID) -> Result<(), ConsulError> {
let node = format!("garage:{}", hex::encode(&node_id[..8]));
let url = format!(
"{}/v1/{}",
self.config.consul_http_addr,
(match &self.config.api {
ConsulDiscoveryAPI::Catalog => format!("catalog/deregister"),
ConsulDiscoveryAPI::Agent => format!("agent/service/deregister/{}", node),
})
);
let req = self.client.put(&url);
let http = if matches!(&self.config.api, ConsulDiscoveryAPI::Catalog) {
let deregister_request = serde_json::json!({
"Node": node,
"ServiceID": node,
});
let req = req.json(&deregister_request);
req.send().await?
} else {
req.send().await?
};
http.error_for_status()?;
debug!("Deregistered service {} from Consul", node);
Ok(())
}
pub async fn publish_consul_service(
&self,
node_id: NodeID,
+10 -1
View File
@@ -358,7 +358,7 @@ impl System {
);
}
pub fn cleanup(&self) {
pub fn cleanup(self: &Arc<Self>) {
// Break reference cycle
self.metrics.store(None);
}
@@ -650,6 +650,15 @@ impl System {
}
}
#[cfg(feature = "consul-discovery")]
pub async fn deregister_from_discovery(self: &Arc<Self>) {
if let Some(c) = &self.consul_discovery {
if let Err(e) = c.deregister_consul_service(self.netapp.id).await {
error!("Error while deregistering from Consul: {}", e);
}
}
}
async fn discovery_loop(self: &Arc<Self>, mut stop_signal: watch::Receiver<bool>) {
while !*stop_signal.borrow() {
let peers_up = self