From cb79e55c9e6b3e828654d21dcc6e35cff5fd8c4c Mon Sep 17 00:00:00 2001 From: Yung Wood Date: Sun, 18 Aug 2024 14:41:10 +0000 Subject: [PATCH] initial commit --- .gitignore | 2 + .gitlab-ci.yml | 19 ++ Dockerfile | 37 +++ README.md | 213 ++++++++++++++ calendar.go | 259 ++++++++++++++++++ charts/ical-filter-proxy/.helmignore | 23 ++ charts/ical-filter-proxy/Chart.yaml | 8 + charts/ical-filter-proxy/templates/NOTES.txt | 22 ++ .../ical-filter-proxy/templates/_helpers.tpl | 62 +++++ .../ical-filter-proxy/templates/config.yaml | 15 + .../templates/deployment.yaml | 79 ++++++ charts/ical-filter-proxy/templates/hpa.yaml | 32 +++ .../ical-filter-proxy/templates/ingress.yaml | 61 +++++ .../ical-filter-proxy/templates/service.yaml | 15 + .../templates/serviceaccount.yaml | 13 + charts/ical-filter-proxy/values.yaml | 119 ++++++++ docker-compose.yaml | 26 ++ go.mod | 18 ++ go.sum | 29 ++ logo.png | Bin 0 -> 32483 bytes main.go | 182 ++++++++++++ renovate.json | 4 + 22 files changed, 1238 insertions(+) create mode 100644 .gitignore create mode 100644 .gitlab-ci.yml create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 calendar.go create mode 100644 charts/ical-filter-proxy/.helmignore create mode 100644 charts/ical-filter-proxy/Chart.yaml create mode 100644 charts/ical-filter-proxy/templates/NOTES.txt create mode 100644 charts/ical-filter-proxy/templates/_helpers.tpl create mode 100644 charts/ical-filter-proxy/templates/config.yaml create mode 100644 charts/ical-filter-proxy/templates/deployment.yaml create mode 100644 charts/ical-filter-proxy/templates/hpa.yaml create mode 100644 charts/ical-filter-proxy/templates/ingress.yaml create mode 100644 charts/ical-filter-proxy/templates/service.yaml create mode 100644 charts/ical-filter-proxy/templates/serviceaccount.yaml create mode 100644 charts/ical-filter-proxy/values.yaml create mode 100644 docker-compose.yaml create mode 100644 go.mod create mode 100644 go.sum create mode 100644 logo.png create mode 100644 main.go create mode 100644 renovate.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ac127d9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/ical-filter-proxy +/config.yaml diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..c98de28 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,19 @@ +--- +include: + - project: gitlab/includes # private gitlab instance + file: + - docker.yml + - go.yml + - helm.yml +variables: + CI_DOCKER_BUILD: "true" + CI_DOCKER_DHUB_REPO: "yungwood/ical-filter-proxy" + +go:build: + artifacts: + paths: + - ical-filter-proxy + +docker:build: + needs: + - go:build diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..2d600a7 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,37 @@ +FROM alpine:3.20.2 + +# build args and defaults +ARG BUILD_DATE="not-set" +ARG REVISION="unknown" +ARG VERSION="dev-build" + +# set some labels +LABEL org.opencontainers.image.created="$BUILD_DATE" \ + org.opencontainers.image.base.name="alpine:3.20.2" \ + org.opencontainers.image.documentation="https://github.com/yungwood/ical-filter-proxy/tree/main/README.md" \ + org.opencontainers.image.licenses="MIT" \ + org.opencontainers.image.source="https://github.com/yungwood/ical-filter-proxy" \ + org.opencontainers.image.revision="$REVISION" \ + org.opencontainers.image.title="iCal Filter Proxy" \ + org.opencontainers.image.description="iCal proxy with support for user-defined filtering rules" \ + org.opencontainers.image.version="$VERSION" + +# install dependencies +RUN apk --no-cache add gcompat=1.1.0-r4 + +# create a group and user +RUN addgroup -S icalfilterproxy && adduser -S -G icalfilterproxy icalfilterproxy + +# switch to app user +USER icalfilterproxy + +# set working dir +WORKDIR /app + +# copy binary +COPY ical-filter-proxy /usr/bin/ical-filter-proxy + +# expose port, define entrypoint +EXPOSE 8080/tcp +ENTRYPOINT ["/usr/bin/ical-filter-proxy"] +CMD ["-config", "/app/config.yaml"] \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..57e0ebf --- /dev/null +++ b/README.md @@ -0,0 +1,213 @@ + +
+
+ + Logo + + +

iCal Filter Proxy

+ +

+ iCal proxy with support for user-defined filtering rules +

+
+ + +## What's this thing? + +Do you have iCal feeds with a bunch of stuff you *don't* need? Do you want to modify events generated by your rostering system? + +iCal Filter Proxy is a simple service for proxying multiple iCal feeds while applying a list of filters to remove or modify events to suit your use case. + +### Features + +* Proxy multiple calendars +* Define a list of filters per calendar +* Match events using basic text and regex conditions +* Remove or modify events as they are proxied + + +### Built With + +* Go +* [golang-ical](https://github.com/arran4/golang-ical) +* [yaml.v3](https://github.com/go-yaml/yaml/tree/v3.0.1) +* [DALL-E 2](https://openai.com/index/dall-e-2/) (app icon) + + +## Setup + +### Docker + +Docker images are published to [Docker Hub](https://hub.docker.com/repository/docker/yungwood/ical-filter-proxy). You'll need a config file (see below) mounted into the container at `/app/config.yaml`. + +For example: + +```bash +docker run -d \ + --name=ical-filter-proxy \ + -v config.yaml:/app/config.yaml \ + -p 8080:8080/tcp \ + --restart unless-stopped \ + yungwood/ical-filter-proxy:latest +``` + +You can also adapt the included [`docker-compose.yaml`](./docker-compose.yaml) example. + +### Kubernetes + +You can deploy iCal Filter Proxy using the included helm chart from [`charts/ical-filter-proxy`](charts/ical-filter-proxy). + +### Build from source + +You can also build the app and container from source. + +```bash +# clone this repo +git clone git@github.com:yungwood/ical-filter-proxy.git +cd ical-filter-proxy + +# build the ical-filter-proxy binary +go build . + +# build container image +docker build \ + --build-arg BUILD_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ") \ + --build-arg REVISION=$(git rev-parse HEAD) \ + --build-arg VERSION=$(git rev-parse --short HEAD) \ + -t ical-filter-proxy:latest . +``` + + +## Configuration + +Calendars and filters are defined in a yaml config file. By default this is `config.yaml` (use the `-config` switch to change this). The configuration must define at least one calendar for ical-filter-proxy to start. + +Example configuration (with comments): + +```yaml +calendars: + + # basic example + - name: example # used as slug in URL - e.g. ical-filter-proxy:8080/calendars/example/feed?token=changeme + token: "changeme" # token used to pull iCal feed - authentication is disabled when blank + feed_url: "https://my-upstream-calendar.url/feed.ics" # URL for the upstream iCal feed + filters: # optional - if no filters defined the upstream calendar is proxied as parsed + - description: "Remove an event based on a regex" + remove: true # events matching this filter will be removed + match: # optional - all events will match if no rules defined + summary: # match on event summary (title) + contains: "deleteme" # must contain 'deleteme' + - description: "Remove descriptions from all events" + transform: # optional + description: # modify event description + remove: true # replace with a blank string + + # example: removing noise from an Office 365 calendar + - name: outlook + token: "changeme" + feed_url: "https://outlook.office365.com/owa/calendar/.../reachcalendar.ics" + filters: + - description: "Remove canceled events" # canceled events remain with a 'Canceled:' prefix until removed + remove: true + match: + summary: + prefix: "Canceled: " + - description: "Remove optional events" + remove: true + match: + summary: + prefix: "[Optional]" + - description: "Remove public holidays" + remove: true + match: + summary: + regex_match: ".*[Pp]ublic [Hh]oliday.*" + + # example: cleaning up an OpsGenie feed + - name: opsgenie + token: "changeme" + feed_url: "https://company.app.opsgenie.com/webapi/webcal/getRecentSchedule?webcalToken=..." + filters: + - description: "Keep oncall schedule events and fix names" + match: + summary: + contains: "schedule: oncall" + stop: true # stops processing any more filters + transform: + summary: + replace: "On-Call" # replace the event summary (title) + - description: "Remove all other events" + remove: true + +unsafe: false # optional - must be enabled if any calendars do not have a token +``` + + +### Filters + +Calendar events are filtered using a similar concept to email filtering. A list of filters is defined for each calendar in the config. + +Each event parsed from `feed_url` is evaluated against the filters in sequence. + +* All `match` rules for a filter must be true to match an event +* A filter with no `match` rules will *always* match +* When a match is found: + * if `remove` is `true` the event is discarded + * `transform` rules are applied to the event + * if `stop` is `true` no more filters are processed +* If no match is found the event is retained by default + +#### Match conditions + +Each filter can spcify match conditions against the following event properties: + +* `summary` (string value) +* `location` (string value) +* `description` (string value) + +These match conditions are available for a string value: + +* `contains` - property must contain this value +* `prefix` - property must start with this value +* `suffix` - property must end with this value +* `regex_match` - property must match the given regular expression (an invalid regex will result in no matches) + +#### Transformations + +Transformations can be applied to the following event properties: + +* `summary` - string value +* `location` - string value +* `description` - string value + +The following transformations are available for strings: + +* `replace` - the property is replace with this value +* `remove` - if `true` the property is set to a blank string + + +## Roadmap to 1.0 + +There are a few more features I would like to add before I call the project "stable" and release version 1.0. + +- [ ] Time based event conditions +- [ ] Caching +- [ ] Support for `ical_url_file` and `token_file` in config (vault secrets) +- [ ] Prometheus metrics +- [ ] Testing + + +## Contributing + +If you have a suggestion that would make this better, please feel free to open an issue or send a pull request. + + +## License + +This project is licensed under the MIT License. See the [LICENSE](./LICENSE) file for details. + + +## Acknowledgments + +This project was inspired by [darkphnx/ical-filter-proxy](https://github.com/darkphnx/ical-filter-proxy). I needed more flexibility with filtering rules and the ability to modify event descriptions... plus I wanted an excuse to finally write something in Go. diff --git a/calendar.go b/calendar.go new file mode 100644 index 0000000..3d4dfa1 --- /dev/null +++ b/calendar.go @@ -0,0 +1,259 @@ +package main + +import ( + "bytes" + "io" + "log/slog" + "net/http" + "regexp" + "strings" + + ics "github.com/arran4/golang-ical" +) + +// All structs defined in this file are used to unmarshall yaml configuration and +// provide helper functions that are used to fetch and filter events + +// CalendarConfig definition +type CalendarConfig struct { + Name string `yaml:"name"` + Token string `yaml:"token"` + FeedURL string `yaml:"feed_url"` + Filters []Filter `yaml:"filters"` +} + +// Downloads iCal feed from the URL and applies filtering rules +func (calendarConfig CalendarConfig) fetch() ([]byte, error) { + + // get the iCal feed + slog.Debug("Fetching iCal feed", "url", calendarConfig.FeedURL) + resp, err := http.Get(calendarConfig.FeedURL) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + feedData, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + // parse calendar + cal, err := ics.ParseCalendar(strings.NewReader(string(feedData))) + if err != nil { + return nil, err + } + + // process filters + if len(calendarConfig.Filters) > 0 { + slog.Debug("Processing filters", "calendar", calendarConfig.Name) + for _, event := range cal.Events() { + if !calendarConfig.ProcessEvent(event) { + cal.RemoveEvent(event.Id()) + } + } + slog.Debug("Filter processing completed", "calendar", calendarConfig.Name) + } else { + slog.Debug("No filters to evaluate", "calendar", calendarConfig.Name) + } + + // serialize output + var buf bytes.Buffer + err = cal.SerializeTo(&buf) + if err != nil { + return nil, err + } + + // return + return buf.Bytes(), nil +} + +// Evaluate the filters for a calendar against a given VEvent and +// perform any transformations directly to the VEvent (pointer) +// This function returns false if an event should be deleted +func (calendarConfig CalendarConfig) ProcessEvent(event *ics.VEvent) bool { + + // Get the Summary (the "title" of the event) + // In case we cannot parse the event summary it should get dropped + summary := event.GetProperty(ics.ComponentPropertySummary) // summary only for logging + if summary == nil { + return false + } + + // Iterate through the Filter rules + for id, filter := range calendarConfig.Filters { + + // Does the filter match the event? + if filter.matchesEvent(*event) { + slog.Debug("Filter match found", "rule_id", id, "filter_description", filter.Description, "event_summary", summary.Value) + + // The event should get dropped if RemoveEvent is set + if filter.RemoveEvent { + slog.Debug("Event to be removed, no more rules will be processed", "action", "DELETE", "rule_id", id, "filter_description", filter.Description, "event_summary", summary.Value) + return false + } + + // Apply transformation rules to event + filter.transformEvent(event) + + // Check if we should stop processing rules + if filter.Stop { + slog.Debug("Stop option is set, no more rules will be processed", "rule_id", id, "filter_description", filter.Description, "event_summary", summary.Value) + return true + } + } + } + + // Keep event by default if all Filter rules are processed + slog.Debug("Rule processing complete, event will be kept", "rule_id", nil, "event_summary", summary.Value) + return true + +} + +// Filter definition +type Filter struct { + Description string `yaml:"description"` + RemoveEvent bool `yaml:"remove"` + Stop bool `yaml:"stop"` + Match EventMatchRules `yaml:"match"` + Transform EventTransformRules `yaml:"transform"` +} + +// Returns true if a VEvent matches the Filter conditions +func (filter Filter) matchesEvent(event ics.VEvent) bool { + + // If an event property is not defined golang-ical returns a nil pointer + + // Get event Summary - only used for debug logging + eventSummary := event.GetProperty(ics.ComponentPropertySummary) + if eventSummary == nil { + slog.Warn("Unable to process event summary. Event will be dropped") + return false // never match if VEvent has no summary + } + + // Check Summary filters against VEvent + if filter.Match.Summary.hasConditions() { + if !filter.Match.Summary.matchesString(eventSummary.Value) { + return false + } + } + + // Check Description filters against VEvent + if filter.Match.Description.hasConditions() { + eventDescription := event.GetProperty(ics.ComponentPropertyDescription) + if eventDescription == nil { + slog.Debug("Event has no Description so cannot not match filter", "event_summary", eventSummary.Value, "filter", filter.Description) + return false // if VEvent has no description it cannot match filter + } + if !filter.Match.Description.matchesString(eventDescription.Value) { + slog.Debug("Event Description does not match filter conditions", "event_summary", eventSummary.Value, "filter", filter.Description) + return false // event doesn't match + } + } + + // Check Description filters against VEvent + if filter.Match.Location.hasConditions() { + eventLocation := event.GetProperty(ics.ComponentPropertyLocation) + if eventLocation == nil { + slog.Warn("Event has no Location so cannot match filter", "event_summary", eventSummary.Value, "filter", filter.Description) + return false // if VEvent has no location it cannot match filter + } + if !filter.Match.Location.matchesString(eventLocation.Value) { + slog.Debug("Event Location does not match filter conditions", "event_summary", eventSummary.Value, "filter", filter.Description) + return false // event doesn't match + + } + } + + // VEvent must match if we get here + slog.Debug("Event matches filter conditions", "event_summary", eventSummary.Value, "filter", filter.Description) + return true +} + +// Applies filter transformations to a VEvent pointer +func (filter Filter) transformEvent(event *ics.VEvent) { + + // Summary transformations + if filter.Transform.Summary.Remove { + event.SetSummary("") + } else if filter.Transform.Summary.Replace != "" { + event.SetSummary(filter.Transform.Summary.Replace) + } + + // Description transformations + if filter.Transform.Description.Remove { + event.SetDescription("") + } else if filter.Transform.Description.Replace != "" { + event.SetDescription(filter.Transform.Description.Replace) + } +} + +// EventMatchRules contains VEvent properties that user can match against +type EventMatchRules struct { + Summary StringMatchRule `yaml:"summary"` + Description StringMatchRule `yaml:"description"` + Location StringMatchRule `yaml:"location"` +} + +// StringMatchRule defines match rules for VEvent properties with string values +type StringMatchRule struct { + Contains string `yaml:"contains"` + Prefix string `yaml:"prefix"` + Suffix string `yaml:"suffix"` + RegexMatch string `yaml:"regex"` +} + +// Returns true if StringMatchRule has any conditions +func (smr StringMatchRule) hasConditions() bool { + return smr.Contains != "" || + smr.Prefix != "" || + smr.Suffix != "" || + smr.RegexMatch != "" +} + +// Returns true if a given string (data) matches ALL StringMatchRule conditions +func (smr StringMatchRule) matchesString(data string) bool { + // check contains if set + if smr.Contains != "" { + if data == "" || !strings.Contains(data, smr.Contains) { + return false + } + } + // check prefix if set + if smr.Prefix != "" { + if data == "" || !strings.HasPrefix(data, smr.Prefix) { + return false + } + } + // check suffix if set + if smr.Suffix != "" { + if data == "" || !strings.HasSuffix(data, smr.Suffix) { + return false + } + } + // check regex match if set + if smr.RegexMatch != "" { + match, err := regexp.MatchString(data, smr.RegexMatch) + if err != nil { + slog.Warn("error processing regex rule", "value", smr.RegexMatch) + return false // regex error is considered a failure to match + } + if !match { + return false // regex didn't match + } + } + return true +} + +// EventTransformRules contains VEvent properties that user can modify +type EventTransformRules struct { + Summary StringTransformRule `yaml:"summary"` + Description StringTransformRule `yaml:"description"` + Location StringTransformRule `yaml:"location"` +} + +// StringTransformRule defines changes for VEvent properties with string values +type StringTransformRule struct { + Replace string `yaml:"replace"` + Remove bool `yaml:"remove"` +} diff --git a/charts/ical-filter-proxy/.helmignore b/charts/ical-filter-proxy/.helmignore new file mode 100644 index 0000000..0e8a0eb --- /dev/null +++ b/charts/ical-filter-proxy/.helmignore @@ -0,0 +1,23 @@ +# Patterns to ignore when building packages. +# This supports shell glob matching, relative path matching, and +# negation (prefixed with !). Only one pattern per line. +.DS_Store +# Common VCS dirs +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +# Common backup files +*.swp +*.bak +*.tmp +*.orig +*~ +# Various IDEs +.project +.idea/ +*.tmproj +.vscode/ diff --git a/charts/ical-filter-proxy/Chart.yaml b/charts/ical-filter-proxy/Chart.yaml new file mode 100644 index 0000000..0834935 --- /dev/null +++ b/charts/ical-filter-proxy/Chart.yaml @@ -0,0 +1,8 @@ +apiVersion: v2 +name: ical-filter-proxy +description: iCal proxy with support for user-defined filtering rules +type: application +version: 0.1.0 +appVersion: "0.1.0" +maintainers: + - name: Yung Wood diff --git a/charts/ical-filter-proxy/templates/NOTES.txt b/charts/ical-filter-proxy/templates/NOTES.txt new file mode 100644 index 0000000..a626be0 --- /dev/null +++ b/charts/ical-filter-proxy/templates/NOTES.txt @@ -0,0 +1,22 @@ +1. Get the application URL by running these commands: +{{- if .Values.ingress.enabled }} +{{- range $host := .Values.ingress.hosts }} + {{- range .paths }} + http{{ if $.Values.ingress.tls }}s{{ end }}://{{ $host.host }}{{ .path }} + {{- end }} +{{- end }} +{{- else if contains "NodePort" .Values.service.type }} + export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "ical-filter-proxy.fullname" . }}) + export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}") + echo http://$NODE_IP:$NODE_PORT +{{- else if contains "LoadBalancer" .Values.service.type }} + NOTE: It may take a few minutes for the LoadBalancer IP to be available. + You can watch its status by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "ical-filter-proxy.fullname" . }}' + export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "ical-filter-proxy.fullname" . }} --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}") + echo http://$SERVICE_IP:{{ .Values.service.port }} +{{- else if contains "ClusterIP" .Values.service.type }} + export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "ical-filter-proxy.name" . }},app.kubernetes.io/instance={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}") + export CONTAINER_PORT=$(kubectl get pod --namespace {{ .Release.Namespace }} $POD_NAME -o jsonpath="{.spec.containers[0].ports[0].containerPort}") + echo "Visit http://127.0.0.1:8080 to use your application" + kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 8080:$CONTAINER_PORT +{{- end }} diff --git a/charts/ical-filter-proxy/templates/_helpers.tpl b/charts/ical-filter-proxy/templates/_helpers.tpl new file mode 100644 index 0000000..5c07908 --- /dev/null +++ b/charts/ical-filter-proxy/templates/_helpers.tpl @@ -0,0 +1,62 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "ical-filter-proxy.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create a default fully qualified app name. +We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). +If release name contains chart name it will be used as a full name. +*/}} +{{- define "ical-filter-proxy.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{/* +Create chart name and version as used by the chart label. +*/}} +{{- define "ical-filter-proxy.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Common labels +*/}} +{{- define "ical-filter-proxy.labels" -}} +helm.sh/chart: {{ include "ical-filter-proxy.chart" . }} +{{ include "ical-filter-proxy.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* +Selector labels +*/}} +{{- define "ical-filter-proxy.selectorLabels" -}} +app.kubernetes.io/name: {{ include "ical-filter-proxy.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{/* +Create the name of the service account to use +*/}} +{{- define "ical-filter-proxy.serviceAccountName" -}} +{{- if .Values.serviceAccount.create }} +{{- default (include "ical-filter-proxy.fullname" .) .Values.serviceAccount.name }} +{{- else }} +{{- default "default" .Values.serviceAccount.name }} +{{- end }} +{{- end }} diff --git a/charts/ical-filter-proxy/templates/config.yaml b/charts/ical-filter-proxy/templates/config.yaml new file mode 100644 index 0000000..bac6e3a --- /dev/null +++ b/charts/ical-filter-proxy/templates/config.yaml @@ -0,0 +1,15 @@ +{{- if .Values.config.enabled -}} +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "ical-filter-proxy.fullname" . }}-config + labels: + {{- include "ical-filter-proxy.labels" . | nindent 4 }} +data: + config.yaml: | + {{- if .Values.config.insecure }} + insecure: true + {{- end }} + calendars: +{{ .Values.config.calendars | toYaml | indent 6 }} +{{- end }} diff --git a/charts/ical-filter-proxy/templates/deployment.yaml b/charts/ical-filter-proxy/templates/deployment.yaml new file mode 100644 index 0000000..46ae7c8 --- /dev/null +++ b/charts/ical-filter-proxy/templates/deployment.yaml @@ -0,0 +1,79 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "ical-filter-proxy.fullname" . }} + labels: + {{- include "ical-filter-proxy.labels" . | nindent 4 }} +spec: + {{- if not .Values.autoscaling.enabled }} + replicas: {{ .Values.replicaCount }} + {{- end }} + selector: + matchLabels: + {{- include "ical-filter-proxy.selectorLabels" . | nindent 6 }} + template: + metadata: + {{- with .Values.podAnnotations }} + annotations: + {{- toYaml . | nindent 8 }} + {{- end }} + labels: + {{- include "ical-filter-proxy.labels" . | nindent 8 }} + {{- with .Values.podLabels }} + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + serviceAccountName: {{ include "ical-filter-proxy.serviceAccountName" . }} + securityContext: + {{- toYaml .Values.podSecurityContext | nindent 8 }} + containers: + - name: {{ .Chart.Name }} + securityContext: + {{- toYaml .Values.securityContext | nindent 12 }} + image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + args: {{ .Values.args }} + ports: + - name: http + containerPort: {{ .Values.service.port }} + protocol: TCP + livenessProbe: + {{- toYaml .Values.livenessProbe | nindent 12 }} + readinessProbe: + {{- toYaml .Values.readinessProbe | nindent 12 }} + resources: + {{- toYaml .Values.resources | nindent 12 }} + volumeMounts: + {{- if .Values.config.enabled }} + - name: config + mountPath: /app/config.yaml + subPath: config.yaml + {{- end }} + {{- with .Values.volumeMounts }} + {{- toYaml . | nindent 12 }} + {{- end }} + volumes: + {{- if .Values.config.enabled }} + - name: config + configMap: + name: {{ include "ical-filter-proxy.fullname" . }}-config + {{- end }} + {{- with .Values.volumes }} + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/charts/ical-filter-proxy/templates/hpa.yaml b/charts/ical-filter-proxy/templates/hpa.yaml new file mode 100644 index 0000000..ee955cd --- /dev/null +++ b/charts/ical-filter-proxy/templates/hpa.yaml @@ -0,0 +1,32 @@ +{{- if .Values.autoscaling.enabled }} +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: {{ include "ical-filter-proxy.fullname" . }} + labels: + {{- include "ical-filter-proxy.labels" . | nindent 4 }} +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: {{ include "ical-filter-proxy.fullname" . }} + minReplicas: {{ .Values.autoscaling.minReplicas }} + maxReplicas: {{ .Values.autoscaling.maxReplicas }} + metrics: + {{- if .Values.autoscaling.targetCPUUtilizationPercentage }} + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }} + {{- end }} + {{- if .Values.autoscaling.targetMemoryUtilizationPercentage }} + - type: Resource + resource: + name: memory + target: + type: Utilization + averageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }} + {{- end }} +{{- end }} diff --git a/charts/ical-filter-proxy/templates/ingress.yaml b/charts/ical-filter-proxy/templates/ingress.yaml new file mode 100644 index 0000000..99f59e7 --- /dev/null +++ b/charts/ical-filter-proxy/templates/ingress.yaml @@ -0,0 +1,61 @@ +{{- if .Values.ingress.enabled -}} +{{- $fullName := include "ical-filter-proxy.fullname" . -}} +{{- $svcPort := .Values.service.port -}} +{{- if and .Values.ingress.className (not (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion)) }} + {{- if not (hasKey .Values.ingress.annotations "kubernetes.io/ingress.class") }} + {{- $_ := set .Values.ingress.annotations "kubernetes.io/ingress.class" .Values.ingress.className}} + {{- end }} +{{- end }} +{{- if semverCompare ">=1.19-0" .Capabilities.KubeVersion.GitVersion -}} +apiVersion: networking.k8s.io/v1 +{{- else if semverCompare ">=1.14-0" .Capabilities.KubeVersion.GitVersion -}} +apiVersion: networking.k8s.io/v1beta1 +{{- else -}} +apiVersion: extensions/v1beta1 +{{- end }} +kind: Ingress +metadata: + name: {{ $fullName }} + labels: + {{- include "ical-filter-proxy.labels" . | nindent 4 }} + {{- with .Values.ingress.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- if and .Values.ingress.className (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion) }} + ingressClassName: {{ .Values.ingress.className }} + {{- end }} + {{- if .Values.ingress.tls }} + tls: + {{- range .Values.ingress.tls }} + - hosts: + {{- range .hosts }} + - {{ . | quote }} + {{- end }} + secretName: {{ .secretName }} + {{- end }} + {{- end }} + rules: + {{- range .Values.ingress.hosts }} + - host: {{ .host | quote }} + http: + paths: + {{- range .paths }} + - path: {{ .path }} + {{- if and .pathType (semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion) }} + pathType: {{ .pathType }} + {{- end }} + backend: + {{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }} + service: + name: {{ $fullName }} + port: + number: {{ $svcPort }} + {{- else }} + serviceName: {{ $fullName }} + servicePort: {{ $svcPort }} + {{- end }} + {{- end }} + {{- end }} +{{- end }} diff --git a/charts/ical-filter-proxy/templates/service.yaml b/charts/ical-filter-proxy/templates/service.yaml new file mode 100644 index 0000000..0d36a9c --- /dev/null +++ b/charts/ical-filter-proxy/templates/service.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "ical-filter-proxy.fullname" . }} + labels: + {{- include "ical-filter-proxy.labels" . | nindent 4 }} +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.service.port }} + targetPort: http + protocol: TCP + name: http + selector: + {{- include "ical-filter-proxy.selectorLabels" . | nindent 4 }} diff --git a/charts/ical-filter-proxy/templates/serviceaccount.yaml b/charts/ical-filter-proxy/templates/serviceaccount.yaml new file mode 100644 index 0000000..0c954e3 --- /dev/null +++ b/charts/ical-filter-proxy/templates/serviceaccount.yaml @@ -0,0 +1,13 @@ +{{- if .Values.serviceAccount.create -}} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "ical-filter-proxy.serviceAccountName" . }} + labels: + {{- include "ical-filter-proxy.labels" . | nindent 4 }} + {{- with .Values.serviceAccount.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +automountServiceAccountToken: {{ .Values.serviceAccount.automount }} +{{- end }} diff --git a/charts/ical-filter-proxy/values.yaml b/charts/ical-filter-proxy/values.yaml new file mode 100644 index 0000000..3ff2019 --- /dev/null +++ b/charts/ical-filter-proxy/values.yaml @@ -0,0 +1,119 @@ +replicaCount: 1 + +image: + repository: yungwood/ical-filter-proxy + pullPolicy: IfNotPresent + tag: "" # defaults to chart appVersion + +imagePullSecrets: [] +nameOverride: "" +fullnameOverride: "" + +args: [] + +config: + enabled: false # enable to generate config from values (example below) + # calendars: + # - name: outlook + # token: "changeme" + # feed_url: "https://outlook.office365.com/owa/calendar/.../reachcalendar.ics" + # filters: + # - description: "Remove cancelled events" + # remove: true + # match: + # summary: + # prefix: "Canceled: " + # - description: "Remove optional events" + # remove: true + # match: + # summary: + # prefix: "[Optional]" + # - description: "Remove public holidays" + # remove: true + # match: + # summary: + # regex_match: ".*[Pp]ublic [Hh]oliday.*" + # insecure: false + +serviceAccount: + create: true + automount: true + annotations: {} + name: "" + +podAnnotations: {} +podLabels: {} + +podSecurityContext: {} + # fsGroup: 2000 + +securityContext: + capabilities: + drop: + - ALL + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + runAsNonRoot: true + runAsUser: 1000 + runAsGroup: 3000 + +service: + type: ClusterIP + port: 8080 + +ingress: + enabled: false + className: "" + annotations: {} + # kubernetes.io/ingress.class: nginx + # kubernetes.io/tls-acme: "true" + hosts: + - host: ical-filter-proxy.local + paths: + - path: / + pathType: ImplementationSpecific + tls: [] + # - secretName: ical-filter-proxy-tls + # hosts: + # - ical-filter-proxy.local + +resources: {} + # limits: + # cpu: 100m + # memory: 128Mi + # requests: + # cpu: 100m + # memory: 128Mi + +livenessProbe: + httpGet: + path: /liveness + port: http +readinessProbe: + httpGet: + path: /readiness + port: http + +autoscaling: + enabled: false + minReplicas: 1 + maxReplicas: 100 + targetCPUUtilizationPercentage: 80 + # targetMemoryUtilizationPercentage: 80 + +volumes: [] +# - name: foo +# secret: +# secretName: mysecret +# optional: false + +volumeMounts: [] +# - name: foo +# mountPath: "/etc/foo" +# readOnly: true + +nodeSelector: {} + +tolerations: [] + +affinity: {} diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..94401d9 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,26 @@ +--- +services: + ical-filter-proxy: + image: yungwood/ical-filter-proxy:latest + container_name: ical-filter-proxy + # # specify additional command arguments + # command: --debug + ports: + - "8080:8080" + volumes: + - ./config.yaml:/app/config.yaml + restart: always + +## the below example might be used with traefik docker provider +## https://doc.traefik.io/traefik/providers/docker/ +# labels: +# traefik.enable: true +# traefik.http.routers.ical.rule: "Host(`ical.mydomain.com`)" +# traefik.http.routers.ical.entrypoints: web +# traefik.http.services.ical.loadbalancer.server.port: 8080 +# networks: +# - traefik +# networks: +# traefik: +# name: traefik +# external: true diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..6cb4cd0 --- /dev/null +++ b/go.mod @@ -0,0 +1,18 @@ +module github.com/yungwood/ical-filter-proxy + +go 1.22.5 + +require ( + github.com/arran4/golang-ical v0.3.1 + github.com/gookit/color v1.5.4 // indirect + github.com/gookit/goutil v0.6.15 // indirect + github.com/gookit/gsr v0.1.0 // indirect + github.com/gookit/slog v0.5.6 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sync v0.5.0 // indirect + golang.org/x/sys v0.22.0 // indirect + golang.org/x/text v0.14.0 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect + gopkg.in/yaml.v3 v3.0.1 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..f7f1f36 --- /dev/null +++ b/go.sum @@ -0,0 +1,29 @@ +github.com/arran4/golang-ical v0.3.1 h1:v13B3eQZ9VDHTAvT6M11vVzxYgcYmjyPBE2eAZl3VZk= +github.com/arran4/golang-ical v0.3.1/go.mod h1:LZWxF8ZIu/sjBVUCV0udiVPrQAgq3V0aa0RfbO99Qkk= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/gookit/color v1.5.4/go.mod h1:pZJOeOS8DM43rXbp4AZo1n9zCU2qjpcRko0b6/QJi9w= +github.com/gookit/goutil v0.6.15/go.mod h1:qdKdYEHQdEtyH+4fNdQNZfJHhI0jUZzHxQVAV3DaMDY= +github.com/gookit/gsr v0.1.0/go.mod h1:7wv4Y4WCnil8+DlDYHBjidzrEzfHhXEoFjEA0pPPWpI= +github.com/gookit/slog v0.5.6/go.mod h1:RfIwzoaQ8wZbKdcqG7+3EzbkMqcp2TUn3mcaSZAw2EQ= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/logo.png b/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..c292f0c5ecbfffd46e0da10643f912886126c7bc GIT binary patch literal 32483 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