Openproject Part 1: OS PjM

Published: Feb 27, 2024 by Isaac Johnson

OpenProject is an Open-Source project management offering that also has a paid SaaS offering as well (similar to Plane.so). I’ll start by saying there are some things I really like about OpenProject and a few big annoyances. There is a lot here and it’s one of the few Open Source PjM Suites I think holds a torch to the heavyweight, JIRA in terms of deep custom configuration.

We’re going to dive in with Kubernetes and Docker. We’re going to look at setup and usage, APIs and REST, and more.

Note: they use the term “work package” for what many might use the work “work item”, “issue”, or “ticket”. So if I fall back to calling things “work items” or “tickets”, it’s only because for me, a “package” is generally a built thing and I find the terminology a bit strange

Background

OpenProject actually forked from ChiliProject in 2015 which had forked from Redmine in 2011. (while Wikipedia claims it forked in 2015, the first company blog post is from 2014 and the Git releases go back to 2012)

While there is an “OpenProject Foundation” that was established in 2012 and registered in the Amtsgericht of Charlottenburg-Wilmersdorf (a district of Berlin, Germany), there is a commercial Enterprise offering developed by the OpenProject company which has about 11-50 employees (29 on LI)

I’ve always loved this model of a totally free and OS version one could just download from Github but also an easy to use and scale SaaS offering.

Getting started

Let’s start with the OS self-hosted version first.

Let’s just keep it simple to start with; a simple helm install

$ helm repo add openproject https://charts.openproject.org
"openproject" has been added to your repositories

$ helm upgrade --create-namespace --namespace openproject --install my-openproject openproject/openproject
Release "my-openproject" does not exist. Installing it now.
coalesce.go:223: warning: destination for memcached.service.sessionAffinity is a table. Ignoring non-table value ()
NAME: my-openproject
LAST DEPLOYED: Fri Feb  9 13:08:16 2024
NAMESPACE: openproject
STATUS: deployed
REVISION: 1
NOTES:
Thank you for installing OpenProject 🎉
You can access it via https://openproject.example.com/

Summary:
--------
OpenProject: 13-slim
PostgreSQL: 15.4.0-debian-11-r45
Memcached: 1.6.23-debian-11-r2

That immediately created 6 pods

$ kubectl get pods -n openproject
NAME                                         READY   STATUS              RESTARTS   AGE
my-openproject-seeder-20240209130816-hrjwp   0/1     Pending             0          33s
my-openproject-web-8d8df9c9b-nz6kh           0/1     Pending             0          34s
my-openproject-worker-67bd58494c-xflzs       0/1     Pending             0          34s
my-openproject-memcached-5cf96d79fc-v85w5    1/1     Running             0          34s
my-openproject-postgresql-0                  0/1     ContainerCreating   0          33s

I initially had some issues with my default storage class. Once corrected, I started to see the pods come up

NAME                                         READY   STATUS     RESTARTS   AGE
my-openproject-web-8d8df9c9b-chd8x           0/1     Init:0/1   0          2m18s
my-openproject-worker-67bd58494c-p4t5v       0/1     Init:0/1   0          2m18s
my-openproject-memcached-5cf96d79fc-tbm2j    1/1     Running    0          2m18s
my-openproject-postgresql-0                  1/1     Running    0          2m17s
my-openproject-seeder-20240209132207-flgft   1/1     Running    0          2m17s

$ kubectl get pods -n openproject
NAME                                        READY   STATUS    RESTARTS   AGE
my-openproject-memcached-5cf96d79fc-tbm2j   1/1     Running   0          134m
my-openproject-postgresql-0                 1/1     Running   0          134m
my-openproject-worker-67bd58494c-p4t5v      1/1     Running   0          134m
my-openproject-web-8d8df9c9b-chd8x          1/1     Running   0          134m

However, this did not work. Neither port-forwarding to the service nor pod seemed to connect

/content/images/2024/02/openproject-01.png

Trying with a disabled SSL

$ helm upgrade --create-namespace --namespace openproject --install my-openproject openproject/openproject --set openproject.https=false --set openproject.admin_user.password="Testing1234" --set openproject.admin_user.name="builder" --set openproject.admin_user.mail="isaac.johnson@gmail.com" --set environment.OPENPROJECT_APP__TITLE='My Freshbrewed OpenProject'
coalesce.go:223: warning: destination for memcached.service.sessionAffinity is a table. Ignoring non-table value ()
Release "my-openproject" has been upgraded. Happy Helming!
NAME: my-openproject
LAST DEPLOYED: Fri Feb  9 16:07:23 2024
NAMESPACE: openproject
STATUS: deployed
REVISION: 2
NOTES:
Thank you for installing OpenProject 🎉
You can access it via https://openproject.example.com/

Summary:
--------
OpenProject: 13-slim
PostgreSQL: 15.4.0-debian-11-r45
Memcached: 1.6.23-debian-11-r2

This time I was able to port-forward to the pod

$ kubectl port-forward my-openproject-web-58845ddbbb-728sz -n openproject 8888:8080
Forwarding from 127.0.0.1:8888 -> 8080
Forwarding from [::1]:8888 -> 8080
Handling connection for 8888

/content/images/2024/02/openproject-02.png

Even though I gave it a password, it did not take. It still used ‘admin/admin’ for the login.

After figuring that out, I was prompted to change passwords

/content/images/2024/02/openproject-03.png

I took a break and came back to a crashing web pod

builder@DESKTOP-QADGF36:~/Workspaces/jekyll-blog$ kubectl get pods -n openproject
NAME                                        READY   STATUS             RESTARTS          AGE
my-openproject-memcached-5cf96d79fc-tbm2j   1/1     Running            0                 18h
my-openproject-worker-7dd5975d97-fzxhn      1/1     Running            0                 15h
my-openproject-postgresql-0                 1/1     Running            0                 18h
my-openproject-web-58845ddbbb-728sz         0/1     CrashLoopBackOff   147 (3m21s ago)   15h
builder@DESKTOP-QADGF36:~/Workspaces/jekyll-blog$ kubectl logs my-openproject-web-58845ddbbb-728sz -n openproject
Defaulted container "openproject" out of: openproject, wait-for-db (init)
=> Booting Puma
=> Rails 7.0.8 application starting in production
=> Run `bin/rails server --help` for more startup options
A server is already running. Check /app/tmp/pids/server.pid.
Exiting

I bounced the pod, but it only stayed up for a short bit before dying again

$ kubectl port-forward my-openproject-web-58845ddbbb-nmj7s -n openproject 8889:8080
Forwarding from 127.0.0.1:8889 -> 8080
Forwarding from [::1]:8889 -> 8080
Handling connection for 8889
Handling connection for 8889
Handling connection for 8889
Handling connection for 8889
Handling connection for 8889
Handling connection for 8889
Handling connection for 8889
Handling connection for 8889
Handling connection for 8889
Handling connection for 8889
Handling connection for 8889
Handling connection for 8889
Handling connection for 8889
E0210 07:57:35.599805    2285 portforward.go:347] error creating error stream for port 8889 -> 8080: Timeout occurred
Handling connection for 8889
E0210 07:59:35.608438    2285 portforward.go:347] error creating error stream for port 8889 -> 8080: Timeout occurred
Handling connection for 8889
E0210 08:01:35.601000    2285 portforward.go:347] error creating error stream for port 8889 -> 8080: Timeout occurred
Handling connection for 8889
E0210 08:03:35.613126    2285 portforward.go:347] error creating error stream for port 8889 -> 8080: Timeout occurred
Handling connection for 8889
E0210 08:05:35.600319    2285 portforward.go:347] error creating error stream for port 8889 -> 8080: Timeout occurred
Handling connection for 8889
E0210 08:07:35.600567    2285 portforward.go:347] error creating error stream for port 8889 -> 8080: Timeout occurred
Handling connection for 8889
E0210 08:09:35.603765    2285 portforward.go:347] error creating error stream for port 8889 -> 8080: Timeout occurred
Handling connection for 8889
E0210 08:11:35.604255    2285 portforward.go:347] error creating error stream for port 8889 -> 8080: Timeout occurred
Handling connection for 8889
Handling connection for 8889
E0210 08:13:35.605406    2285 portforward.go:347] error creating error stream for port 8889 -> 8080: Timeout occurred

In the end, I updated the deployment so i would have at least 3 web pods behind the service

$ kubectl edit deployment -n openproject my-openproject-web
deployment.apps/my-openproject-web edited
$ kubectl get rs -n openproject
NAME                                  DESIRED   CURRENT   READY   AGE
my-openproject-web-8d8df9c9b          0         0         0       22h
my-openproject-worker-67bd58494c      0         0         0       22h
my-openproject-memcached-5cf96d79fc   1         1         1       22h
my-openproject-worker-7dd5975d97      1         1         1       19h
my-openproject-web-58845ddbbb         3         3         0       19h

Then when up, I went to the service

$ kubectl port-forward svc/my-openproject -n openproject 9993:8080
Forwarding from 127.0.0.1:9993 -> 8080
Forwarding from [::1]:9993 -> 8080
Handling connection for 9993
Handling connection for 9993
Handling connection for 9993

I can see “My Page” which shows me packages assigned and created by me

/content/images/2024/02/openproject-04.png

By default, we have a couple of Projects created for us

/content/images/2024/02/openproject-05.png

In Demo project, I can see a broken image

/content/images/2024/02/openproject-06.png

I can double click a widget, like the Welcome one to edit

/content/images/2024/02/openproject-07.png

That came back as a broken image on save. I believe this is due to “hostname” settings.

We can go into Administration/General to adjust

/content/images/2024/02/openproject-08.png

Interesting, that field is not editable. I can change the numeric fields, but not the hostname

I believe it’s because by default ingress is enabled meaning it did try and create an ingress with the example hostname

$ kubectl get ingress -n openproject
NAME             CLASS    HOSTS                     ADDRESS   PORTS     AGE
my-openproject   <none>   openproject.example.com             80, 443   22h

Let’s sort that out now to see if it helps

$ cat r53-openproject.json
{
    "Comment": "CREATE openproject fb.s A record ",
    "Changes": [
      {
        "Action": "CREATE",
        "ResourceRecordSet": {
          "Name": "openproject.freshbrewed.science",
          "Type": "A",
          "TTL": 300,
          "ResourceRecords": [
            {
              "Value": "75.73.224.240"
            }
          ]
        }
      }
    ]
  }

$ aws route53 change-resource-record-sets --hosted-zone-id Z39E8QFU0F9PZP --change-batch file://r53-openproject.json
{
    "ChangeInfo": {
        "Id": "/change/C0358323ML3CD0LEI3D3",
        "Status": "PENDING",
        "SubmittedAt": "2024-02-10T18:14:10.162Z",
        "Comment": "CREATE openproject fb.s A record "
    }
}

I’ll make a values file

backgroundReplicaCount: 2
environment:
  OPENPROJECT_APP__TITLE: My Freshbrewed OpenProject
openproject:
  admin_user:
    mail: isaac.johnson@gmail.com
    name: builder
    password: Testing1234
  https: true
ingress:
  annotations:
    cert-manager.io/cluster-issuer: letsencrypt-prod
    ingress.kubernetes.io/proxy-body-size: "0"
    ingress.kubernetes.io/ssl-redirect: "true"
    kubernetes.io/ingress.class: nginx
    nginx.ingress.kubernetes.io/proxy-body-size: "0"
    nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
    nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
    nginx.ingress.kubernetes.io/ssl-redirect: "true"
    nginx.org/client-max-body-size: "0"
    nginx.org/proxy-connect-timeout: "3600"
    nginx.org/proxy-read-timeout: "3600"
  enabled: true
  host: openproject.freshbrewed.science
  ingressClassName: null
  path: /
  pathType: Prefix
  tls:
    enabled: true
    secretName: openproject-tls

and upgrade

$ helm upgrade --create-namespace --namespace openproject --install my-openproject openproject/openproject -f ./openproject-values.yaml
coalesce.go:223: warning: destination for memcached.service.sessionAffinity is a table. Ignoring non-table value ()
Release "my-openproject" has been upgraded. Happy Helming!
NAME: my-openproject
LAST DEPLOYED: Sat Feb 10 12:23:19 2024
NAMESPACE: openproject
STATUS: deployed
REVISION: 3
NOTES:
Thank you for installing OpenProject 🎉
You can access it via https://openproject.freshbrewed.science/

Summary:
--------
OpenProject: 13-slim
PostgreSQL: 15.4.0-debian-11-r45
Memcached: 1.6.23-debian-11-r2

Now I’ll show the ingress

$ kubectl get ingress -n openproject
NAME             CLASS    HOSTS                             ADDRESS                                                PORTS     AGE
my-openproject   <none>   openproject.freshbrewed.science   192.168.1.215,192.168.1.36,192.168.1.57,192.168.1.78   80, 443   23h

with SSL enabled, we see bad certs

/content/images/2024/02/openproject-09.png

It actually was just taking time to fetch the LE cert. Soon it came back up

/content/images/2024/02/openproject-10.png

However, even updating via attachments seems to fail. I get a 502 error

/content/images/2024/02/openproject-11.png

I followed the logs and the web pod seems to want to write to /tmp but does not like it being 777 perms

/usr/local/lib/ruby/3.2.0/tmpdir.rb:34:in `block in tmpdir': system temporary path is world-writable: /tmp (StructuredWarnings::StandardWarning)
/usr/local/lib/ruby/3.2.0/tmpdir.rb:34:in `block in tmpdir': /tmp is world-writable: /tmp (StructuredWarnings::StandardWarning)
2024-02-10 21:08:51 +0000 Read: #<Errno::EROFS: Read-only file system @ rb_sysopen - /app/puma20240210-13-1nyzz9>
I, [2024-02-10T21:08:51.710983 #16]  INFO -- : [1c9a9a25-8369-40f5-8848-cb2add52aa8e] duration=151.20 db=111.53 view=39.67 status=200 method=GET path=/api/v3/notifications params={"pageSize"=>"0", "filters"=>"[{\"readIAN\":{\"operator\":\"=\",\"values\":[\"f\"]}}]"} host=openproject.freshbrewed.science user=4
I, [2024-02-10T21:08:52.190344 #16]  INFO -- : [d2a4fb4a-f9a7-46fd-8185-f3dcea621c17] method=GET path=/health_checks/default format=*/* controller=OkComputer::OkComputerController action=show status=200 allocations=483 duration=15.34 view=0.36 db=0.00 user=3

Which I can see

$ kubectl exec -it my-openproject-web-79d558499c-tjrmd -n openproject -- /bin/sh
Defaulted container "openproject" out of: openproject, wait-for-db (init)
$ ls -ltra /tmp
total 8
drwxrwxrwx 2 root root 4096 Feb 10 18:23 .
drwxr-xr-x 1 root root 4096 Feb 10 18:24 ..

This is a bit of an issue as I cannot just create a task

/content/images/2024/02/openproject-13.png

And then try and add an attachment as I get a 502 error

/content/images/2024/02/openproject-12.png

Also, i can’t just port-forward to the service because it is doing some SSL funny business

$ kubectl port-forward svc/my-openproject -n openproject 8080:8080
Forwarding from 127.0.0.1:8080 -> 8080
Forwarding from [::1]:8080 -> 8080
Handling connection for 8080
Handling connection for 8080
Handling connection for 8080

/content/images/2024/02/openproject-14.png

And switching back to http not only also fails

/content/images/2024/02/openproject-15.png

But I get a footer complaining about it

/content/images/2024/02/openproject-16.png

AKS

I had an AKS cluster up so I decided to circle back and try there

$ helm upgrade --create-namespace --namespace openproject --install my-openproject openproject/openproject
Release "my-openproject" does not exist. Installing it now.
coalesce.go:223: warning: destination for memcached.service.sessionAffinity is a table. Ignoring non-table value ()
NAME: my-openproject
LAST DEPLOYED: Mon Feb 19 08:02:38 2024
NAMESPACE: openproject
STATUS: deployed
REVISION: 1
NOTES:
Thank you for installing OpenProject 🎉
You can access it via https://openproject.example.com/

Summary:
--------
OpenProject: 13-slim
PostgreSQL: 15.4.0-debian-11-r45
Memcached: 1.6.23-debian-11-r2

$ kubectl get ingress -n openproject
NAME             CLASS    HOSTS                     ADDRESS   PORTS     AGE
my-openproject   <none>   openproject.example.com             80, 443   87s

$ kubectl get svc -n openproject
NAME                           TYPE        CLUSTER-IP     EXTERNAL-IP   PORT(S)     AGE
my-openproject                 ClusterIP   10.0.194.107   <none>        8080/TCP    93s
my-openproject-memcached       ClusterIP   10.0.39.226    <none>        11211/TCP   93s
my-openproject-postgresql      ClusterIP   10.0.146.225   <none>        5432/TCP    93s
my-openproject-postgresql-hl   ClusterIP   None           <none>        5432/TCP    93s

I’ll use a real DNS entry and create an A record

/content/images/2024/02/openproject-120.png

Then upgrade

$ helm upgrade --create-namespace --namespace openproject --install my-openproject openproject/openproject --set ingress.host=openproject.homehealthsre.site
coalesce.go:223: warning: destination for memcached.service.sessionAffinity is a table. Ignoring non-table value ()
Release "my-openproject" has been upgraded. Happy Helming!
NAME: my-openproject
LAST DEPLOYED: Mon Feb 19 08:06:56 2024
NAMESPACE: openproject
STATUS: deployed
REVISION: 2
NOTES:
Thank you for installing OpenProject 🎉
You can access it via https://openproject.homehealthsre.site/

Summary:
--------
OpenProject: 13-slim
PostgreSQL: 15.4.0-debian-11-r45
Memcached: 1.6.23-debian-11-r2

Which shows it updated

$ kubectl get ingress -n openproject
NAME             CLASS    HOSTS                            ADDRESS   PORTS     AGE
my-openproject   <none>   openproject.homehealthsre.site             80, 443   5m30s

It’s not requesting an IP so there is nothing to which to route

Nginx Controller

I’ll add an NGinx Ingress controller

$ kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/controller-v1.3.0/deploy/static/provider/cloud/deploy.yaml
namespace/ingress-nginx created
serviceaccount/ingress-nginx created
serviceaccount/ingress-nginx-admission created
role.rbac.authorization.k8s.io/ingress-nginx created
role.rbac.authorization.k8s.io/ingress-nginx-admission created
clusterrole.rbac.authorization.k8s.io/ingress-nginx created
clusterrole.rbac.authorization.k8s.io/ingress-nginx-admission created
rolebinding.rbac.authorization.k8s.io/ingress-nginx created
rolebinding.rbac.authorization.k8s.io/ingress-nginx-admission created
clusterrolebinding.rbac.authorization.k8s.io/ingress-nginx created
clusterrolebinding.rbac.authorization.k8s.io/ingress-nginx-admission created
configmap/ingress-nginx-controller created
service/ingress-nginx-controller created
service/ingress-nginx-controller-admission created
deployment.apps/ingress-nginx-controller created
job.batch/ingress-nginx-admission-create created
job.batch/ingress-nginx-admission-patch created
ingressclass.networking.k8s.io/nginx created
validatingwebhookconfiguration.admissionregistration.k8s.io/ingress-nginx-admission created

$ kubectl get pods --namespace ingress-nginx
NAME                                        READY   STATUS      RESTARTS   AGE
ingress-nginx-admission-create-qzk8s        0/1     Completed   0          37s
ingress-nginx-admission-patch-zwxqm         0/1     Completed   0          37s
ingress-nginx-controller-6649cbd66c-xd8gc   1/1     Running     0          37s

Use the new external IP in the DNS entry

$ kubectl get svc -n ingress-nginx
NAME                                 TYPE           CLUSTER-IP   EXTERNAL-IP     PORT(S)                      AGE
ingress-nginx-controller             LoadBalancer   10.0.47.97   172.169.40.54   80:30751/TCP,443:32127/TCP   109s
ingress-nginx-controller-admission   ClusterIP      10.0.6.188   <none>          443/TCP                      108s

My TTLs are bit long so I’ll use a new A record (openproject2)

/content/images/2024/02/openproject-121.png

I’ll now upgrade the helm to use that name AND set the ingress class

$ helm upgrade --create-namespace --namespace openproject --install my-openproject openproject/openproject --set ingress.host=openproject2.homehealthsre.site --set ingress.ingressClassName=nginx
coalesce.go:223: warning: destination for memcached.service.sessionAffinity is a table. Ignoring non-table value ()
Release "my-openproject" has been upgraded. Happy Helming!
NAME: my-openproject
LAST DEPLOYED: Mon Feb 19 08:15:01 2024
NAMESPACE: openproject
STATUS: deployed
REVISION: 3
NOTES:
Thank you for installing OpenProject 🎉
You can access it via https://openproject2.homehealthsre.site/

Summary:
--------
OpenProject: 13-slim
PostgreSQL: 15.4.0-debian-11-r45
Memcached: 1.6.23-debian-11-r2

This didn’t work, however, a bit later, I tried with a different valid A Record as I realized the old DNS has expired last week.

I set a new A Record

/content/images/2024/02/openproject-140.png

Our updated values

$ cat t.values
ingress:
  host: openproject.tpk.pw
  ingressClassName: nginx

Then upgrade

$ helm upgrade -f ./t.values --create-namespace --namespace openproject --install my-openproject openproject/openproject --set openproject.https=false --set openproject.admin_user.password="Testing1234" --set openproject.admin_user.name="builder" --set openproject.admin_user.mail="isaac.johnson@gmail.com" --set environment.OPENPROJECT_APP__TITLE='My Freshbrewed OpenProject'
Release "my-openproject" does not exist. Installing it now.
coalesce.go:223: warning: destination for memcached.service.sessionAffinity is a table. Ignoring non-table value ()
NAME: my-openproject
LAST DEPLOYED: Mon Feb 19 18:56:44 2024
NAMESPACE: openproject
STATUS: deployed
REVISION: 1
NOTES:
Thank you for installing OpenProject 🎉
You can access it via https://openproject.tpk.pw/

Summary:
--------
OpenProject: 13-slim
PostgreSQL: 15.4.0-debian-11-r45
Memcached: 1.6.23-debian-11-r2

For some reason the DB is not being noticed by the other pods

$ kubectl get pods -n openproject
NAME                                         READY   STATUS    RESTARTS   AGE
my-openproject-memcached-6c498d4dfb-m2rhz    1/1     Running   0          6m46s
my-openproject-postgresql-0                  1/1     Running   0          6m46s
my-openproject-seeder-20240219185646-bwf5v   0/1     Pending   0          6m46s
my-openproject-web-58d7dd5b9f-rmdpx          0/1     Pending   0          6m46s
my-openproject-worker-7f9b6f4f5b-4zdng       0/1     Pending   0          6m46s

But the ingress works

$ kubectl get ingress -n openproject
NAME             CLASS   HOSTS                ADDRESS         PORTS     AGE
my-openproject   nginx   openproject.tpk.pw   172.169.40.54   80, 443   7m17s

My issue turned out to be PVC storage class issue which I corrected with the values:

$ cat t.values
ingress:
  host: openproject.tpk.pw
  ingressClassName: nginx

persistence:
  storageClassName: azurefile-csi-premium

It took some testing to find the right storageClasses

$ kubectl patch storageclass default -p '{"metadata": {"annotations":{"storageclass.kubernetes.io/is-default-class":"false"}}}'
$ kubectl patch storageclass managed-csi-premium -p '{"metadata": {"annotations":{"storageclass.kubernetes.io/is-default-class":"true"}}}'

Then a quick helm upgrade (after a delete and deleting the old psql pvc)

$ helm delete my-openproject -n openproject
$ kubectl delete pvc data-my-openproject-postgresql-0 -n openproject

$ helm upgrade -f ./t.values --create-namespace --namespace openproject --install my-openproject openproject/openproject --set openproject.https=false --set openproject.admin_user.password="Testing1234" --set openproject.admin_user.name="builder" --set openproject.admin_user.mail="isaac.johnson@gmail.com" --set environment.OPENPROJECT_APP__TITLE='My Freshbrewed OpenProject'
$ kubectl get pods -n openproject
NAME                                         READY   STATUS      RESTARTS   AGE
my-openproject-memcached-6c498d4dfb-khb8f    1/1     Running     0          4m51s
my-openproject-postgresql-0                  1/1     Running     0          4m51s
my-openproject-seeder-20240219193218-x2q6n   0/1     Completed   0          4m51s
my-openproject-web-58d7dd5b9f-mpntm          1/1     Running     0          4m51s
my-openproject-worker-7f9b6f4f5b-jsxp7       1/1     Running     0          4m51s

It worked, albeit i locked the account up right away

/content/images/2024/02/openproject-141.png

The TLS would be valid I had added a ClusterIssuer to LE, but this proves AKS would work to host the suite

Docker

Let’s go the Docker Compose route instead

builder@builder-T100:~$ git clone https://github.com/opf/openproject-deploy --depth=1 --branch=stable/13 openproject
Cloning into 'openproject'...
remote: Enumerating objects: 21, done.
remote: Counting objects: 100% (21/21), done.
remote: Compressing objects: 100% (16/16), done.
Receiving objects: 100% (21/21), 6.07 KiB | 6.07 MiB/s, done.
remote: Total 21 (delta 0), reused 12 (delta 0), pack-reused 0
builder@builder-T100:~$ cd openproject/compose/

I’ll install Docker Compose

$ sudo apt install docker-compose
Reading package lists... Done
Building dependency tree... Done
Reading state information... Done
The following additional packages will be installed:
  containerd.io docker-ce docker-ce-cli python3-attr python3-distutils python3-docker python3-dockerpty python3-docopt python3-dotenv python3-jsonschema python3-pyrsistent python3-setuptools
  python3-texttable python3-websocket
Suggested packages:
  aufs-tools cgroupfs-mount | cgroup-lite python-attr-doc python-jsonschema-doc python-setuptools-doc
Recommended packages:
  docker.io
The following NEW packages will be installed:
  docker-compose python3-attr python3-distutils python3-docker python3-dockerpty python3-docopt python3-dotenv python3-jsonschema python3-pyrsistent python3-setuptools python3-texttable python3-websocket
The following packages will be upgraded:
  containerd.io docker-ce docker-ce-cli
3 upgraded, 12 newly installed, 0 to remove and 135 not upgraded.
Need to get 68.5 MB of archives.
After this operation, 10.3 MB of additional disk space will be used.
Do you want to continue? [Y/n] y
Get:1 http://us.archive.ubuntu.com/ubuntu jammy-updates/main amd64 python3-distutils all 3.10.8-1~22.04 [139 kB]
Get:2 https://download.docker.com/linux/ubuntu focal/stable amd64 containerd.io amd64 1.6.28-1 [29.6 MB]
Get:3 http://us.archive.ubuntu.com/ubuntu jammy/universe amd64 python3-websocket all 1.2.3-1 [34.7 kB]
Get:4 http://us.archive.ubuntu.com/ubuntu jammy/universe amd64 python3-docker all 5.0.3-1 [89.3 kB]
Get:5 http://us.archive.ubuntu.com/ubuntu jammy/universe amd64 python3-dockerpty all 0.4.1-2 [11.1 kB]
Get:6 http://us.archive.ubuntu.com/ubuntu jammy/universe amd64 python3-docopt all 0.6.2-4 [26.9 kB]
Get:7 http://us.archive.ubuntu.com/ubuntu jammy/universe amd64 python3-dotenv all 0.19.2-1 [20.5 kB]
Get:8 http://us.archive.ubuntu.com/ubuntu jammy/main amd64 python3-attr all 21.2.0-1 [44.0 kB]
Get:9 http://us.archive.ubuntu.com/ubuntu jammy-updates/main amd64 python3-setuptools all 59.6.0-1.2ubuntu0.22.04.1 [339 kB]
Get:10 http://us.archive.ubuntu.com/ubuntu jammy/main amd64 python3-pyrsistent amd64 0.18.1-1build1 [55.5 kB]
Get:11 http://us.archive.ubuntu.com/ubuntu jammy/main amd64 python3-jsonschema all 3.2.0-0ubuntu2 [43.1 kB]
Get:12 http://us.archive.ubuntu.com/ubuntu jammy/universe amd64 python3-texttable all 1.6.4-1 [11.4 kB]
Get:13 http://us.archive.ubuntu.com/ubuntu jammy/universe amd64 docker-compose all 1.29.2-1 [95.8 kB]
Get:14 https://download.docker.com/linux/ubuntu focal/stable amd64 docker-ce-cli amd64 5:25.0.3-1~ubuntu.20.04~focal [13.7 MB]
Get:15 https://download.docker.com/linux/ubuntu focal/stable amd64 docker-ce amd64 5:25.0.3-1~ubuntu.20.04~focal [24.3 MB]
Fetched 68.5 MB in 1s (46.0 MB/s)
(Reading database ... 247222 files and directories currently installed.)
Preparing to unpack .../00-containerd.io_1.6.28-1_amd64.deb ...
Unpacking containerd.io (1.6.28-1) over (1.6.21-1) ...
Preparing to unpack .../01-docker-ce-cli_5%3a25.0.3-1~ubuntu.20.04~focal_amd64.deb ...
Unpacking docker-ce-cli (5:25.0.3-1~ubuntu.20.04~focal) over (5:24.0.2-1~ubuntu.20.04~focal) ...
Preparing to unpack .../02-docker-ce_5%3a25.0.3-1~ubuntu.20.04~focal_amd64.deb ...
Unpacking docker-ce (5:25.0.3-1~ubuntu.20.04~focal) over (5:24.0.2-1~ubuntu.20.04~focal) ...
Selecting previously unselected package python3-distutils.
Preparing to unpack .../03-python3-distutils_3.10.8-1~22.04_all.deb ...
Unpacking python3-distutils (3.10.8-1~22.04) ...
Selecting previously unselected package python3-websocket.
Preparing to unpack .../04-python3-websocket_1.2.3-1_all.deb ...
Unpacking python3-websocket (1.2.3-1) ...
Selecting previously unselected package python3-docker.
Preparing to unpack .../05-python3-docker_5.0.3-1_all.deb ...
Unpacking python3-docker (5.0.3-1) ...
Selecting previously unselected package python3-dockerpty.
Preparing to unpack .../06-python3-dockerpty_0.4.1-2_all.deb ...
Unpacking python3-dockerpty (0.4.1-2) ...
Selecting previously unselected package python3-docopt.
Preparing to unpack .../07-python3-docopt_0.6.2-4_all.deb ...
Unpacking python3-docopt (0.6.2-4) ...
Selecting previously unselected package python3-dotenv.
Preparing to unpack .../08-python3-dotenv_0.19.2-1_all.deb ...
Unpacking python3-dotenv (0.19.2-1) ...
Selecting previously unselected package python3-attr.
Preparing to unpack .../09-python3-attr_21.2.0-1_all.deb ...
Unpacking python3-attr (21.2.0-1) ...
Selecting previously unselected package python3-setuptools.
Preparing to unpack .../10-python3-setuptools_59.6.0-1.2ubuntu0.22.04.1_all.deb ...
Unpacking python3-setuptools (59.6.0-1.2ubuntu0.22.04.1) ...
Selecting previously unselected package python3-pyrsistent:amd64.
Preparing to unpack .../11-python3-pyrsistent_0.18.1-1build1_amd64.deb ...
Unpacking python3-pyrsistent:amd64 (0.18.1-1build1) ...
Selecting previously unselected package python3-jsonschema.
Preparing to unpack .../12-python3-jsonschema_3.2.0-0ubuntu2_all.deb ...
Unpacking python3-jsonschema (3.2.0-0ubuntu2) ...
Selecting previously unselected package python3-texttable.
Preparing to unpack .../13-python3-texttable_1.6.4-1_all.deb ...
Unpacking python3-texttable (1.6.4-1) ...
Selecting previously unselected package docker-compose.
Preparing to unpack .../14-docker-compose_1.29.2-1_all.deb ...
Unpacking docker-compose (1.29.2-1) ...
Setting up python3-dotenv (0.19.2-1) ...
Setting up python3-distutils (3.10.8-1~22.04) ...
Setting up python3-attr (21.2.0-1) ...
Setting up python3-texttable (1.6.4-1) ...
Setting up python3-docopt (0.6.2-4) ...
Setting up python3-setuptools (59.6.0-1.2ubuntu0.22.04.1) ...
Setting up containerd.io (1.6.28-1) ...
Setting up docker-ce-cli (5:25.0.3-1~ubuntu.20.04~focal) ...
Setting up python3-pyrsistent:amd64 (0.18.1-1build1) ...
Setting up python3-websocket (1.2.3-1) ...
Setting up python3-dockerpty (0.4.1-2) ...
Setting up python3-docker (5.0.3-1) ...
Setting up python3-jsonschema (3.2.0-0ubuntu2) ...
Setting up docker-ce (5:25.0.3-1~ubuntu.20.04~focal) ...
Installing new version of config file /etc/default/docker ...
Installing new version of config file /etc/init.d/docker ...
Removing obsolete conffile /etc/init/docker.conf ...
Setting up docker-compose (1.29.2-1) ...
Processing triggers for man-db (2.10.2-1) ...

I can now do a docker compose pull

$ docker-compose pull
Pulling db       ... done
Pulling cache    ... done
Pulling autoheal ... done
Pulling seeder   ... done
Pulling cron     ... done
Pulling worker   ... done
Pulling web      ... done
Pulling proxy    ... done

I can now launch it:

builder@builder-T100:~/openproject/compose$ OPENPROJECT_HTTPS=false docker-compose up -d
Creating network "compose_backend" with the default driver
Creating network "compose_default" with the default driver
Creating network "compose_frontend" with the default driver
Creating volume "compose_pgdata" with default driver
Creating volume "compose_opdata" with default driver
Creating compose_cache_1    ... done
Creating compose_autoheal_1 ... done
Creating compose_seeder_1   ... done
Creating compose_db_1       ... done
Creating compose_web_1      ... done
Creating compose_cron_1     ... done
Creating compose_worker_1   ... done
Creating compose_proxy_1    ... done

I can see it came up, but seems to want to take ports 8080 and 80

/content/images/2024/02/openproject-17.png

I can now try to sign in

/content/images/2024/02/openproject-18.png

This time, after setting the admin password, I can see working images

/content/images/2024/02/openproject-19.png

I’m going to delete the helm install

$ helm list -n openproject
NAME            NAMESPACE       REVISION        UPDATED                                 STATUS          CHART                   APP VERSION
my-openproject  openproject     4               2024-02-10 15:15:29.235846849 -0600 CST deployed        openproject-4.5.0       13
$ helm delete my-openproject -n openproject
release "my-openproject" uninstalled

I’ll cleanup the unneeded PVC

$ kubectl get pvc -n openproject
NAME                               STATUS   VOLUME                                     CAPACITY   ACCESS MODES   STORAGECLASS          AGE
data-my-openproject-postgresql-0   Bound    pvc-33c805b4-23b3-45b8-81c8-066ef5653d76   8Gi        RWO            managed-nfs-storage   2d3h
$ kubectl delete pvc data-my-openproject-postgresql-0 -n openproject
persistentvolumeclaim "data-my-openproject-postgresql-0" deleted

Now that helm is cleared out, we can apply via a Kubernetes manifest YAML using an External IP

$ cat docker.hosted.openproject.yaml
---
apiVersion: v1
kind: Endpoints
metadata:
  name: openproject-external-ip
subsets:
- addresses:
  - ip: 192.168.1.100
  ports:
  - name: openproject
    port: 8080
    protocol: TCP
---
apiVersion: v1
kind: Service
metadata:
  name: openproject-external-ip
spec:
  clusterIP: None
  clusterIPs:
  - None
  internalTrafficPolicy: Cluster
  ipFamilies:
  - IPv4
  - IPv6
  ipFamilyPolicy: RequireDualStack
  ports:
  - name: openproject
    port: 80
    protocol: TCP
    targetPort: 8080
  sessionAffinity: None
  type: ClusterIP
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  annotations:
    cert-manager.io/cluster-issuer: letsencrypt-prod
    kubernetes.io/ingress.class: nginx
    kubernetes.io/tls-acme: "true"
    nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
    nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
    nginx.org/websocket-services: openproject-external-ip
  generation: 1
  labels:
    app.kubernetes.io/instance: openprojectingress
  name: openprojectingress
spec:
  rules:
  - host: openproject.freshbrewed.science
    http:
      paths:
      - backend:
          service:
            name: openproject-external-ip
            port:
              number: 80
        path: /
        pathType: ImplementationSpecific
  tls:
  - hosts:
    - openproject.freshbrewed.science
    secretName: openproject-tls

$ kubectl apply -f docker.hosted.openproject.yaml
endpoints/openproject-external-ip created
service/openproject-external-ip created
ingress.networking.k8s.io/openprojectingress created

This works, but it clearly isn’t happy about this setup (seeing the footer)

/content/images/2024/02/openproject-20.png

I’ll try setting the values and relaunching

builder@builder-T100:~$ cd openproject/compose/
builder@builder-T100:~/openproject/compose$ docker compose down
[+] Running 11/11
 ✔ Container compose_proxy_1     Removed                                                                                                                                                                                          10.5s
 ✔ Container compose_autoheal_1  Removed                                                                                                                                                                                           7.8s
 ✔ Container compose_worker_1    Removed                                                                                                                                                                                          10.5s
 ✔ Container compose_cron_1      Removed                                                                                                                                                                                          10.4s
 ✔ Container compose_web_1       Removed                                                                                                                                                                                           0.6s
 ✔ Container compose_seeder_1    Removed                                                                                                                                                                                           0.1s
 ✔ Container compose_db_1        Removed                                                                                                                                                                                           0.3s
 ✔ Container compose_cache_1     Removed                                                                                                                                                                                           1.1s
 ✔ Network compose_default       Removed                                                                                                                                                                                           0.3s
 ✔ Network compose_backend       Removed                                                                                                                                                                                           0.8s
 ✔ Network compose_frontend      Removed                                                                                                                                                                                           0.6s
builder@builder-T100:~/openproject/compose$ OPENPROJECT_HOST__NAME="openproject.freshbrewed.science" OPENPROJECT_HTTPS=true docker-compose up -d
Creating network "compose_backend" with the default driver
Creating network "compose_default" with the default driver
Creating network "compose_frontend" with the default driver
Creating compose_autoheal_1 ... done
Creating compose_cache_1    ... done
Creating compose_seeder_1   ... done
Creating compose_db_1       ... done
Creating compose_web_1      ... done
Creating compose_cron_1     ... done
Creating compose_worker_1   ... done
Creating compose_proxy_1    ... done

And we can see that cleared the footer complaints

/content/images/2024/02/openproject-21.png

Let’s create a new project and card

We created an account in the recording above, but as we can see, it still has to be activated

/content/images/2024/02/openproject-23.png

As an admin, I can go in and see a new account was put in there

/content/images/2024/02/openproject-24.png

and activate it

/content/images/2024/02/openproject-25.png

If I go to create a personal page, I can see more advertisements

/content/images/2024/02/openproject-26.png

From an old post it’s clear that OP doesn’t see the need to hide those advertisements.

We see them everywhere - from “Placeholder users”

/content/images/2024/02/openproject-27.png

To Team Planner

/content/images/2024/02/openproject-28.png

To Notification Settings

/content/images/2024/02/openproject-29.png

Even tweaking the color scheme is an Enterprise feature

/content/images/2024/02/openproject-30.png

Which starts, presently, at $435 a year for the smallest offering (5 users at 1 year)

/content/images/2024/02/openproject-31.png

Later, when I setup SaaS, I went back to confirm this; indeed, the SaaS offering is the same price

/content/images/2024/02/openproject-68.png

But that one at least as a monthly option for US $42.5 for 5 users

/content/images/2024/02/openproject-69.png

With everything incrementing by 5

/content/images/2024/02/openproject-70.png

Webhooks

I tried creating a demo webhook. Since I couldn’t set the payload JSON, I set it to use webhook.site to test

/content/images/2024/02/openproject-34.png

However, in creating tasks and updating them showed no results

/content/images/2024/02/openproject-32.png

But adding an attachment did trigger it

/content/images/2024/02/openproject-35.png

Actually, checking back later, the API did trigger several events, just a bit out of order and on a few minute delay.

/content/images/2024/02/openproject-36.png

And the Work Package (ticket) being created

/content/images/2024/02/openproject-37.png

{
  "action": "work_package:created",
  "work_package": {
    "_type": "WorkPackage",
    "id": 38,
    "lockVersion": 1,
    "subject": "test",
    "description": {
      "format": "markdown",
      "raw": "asdfsadfsadf",
      "html": "<p class=\"op-uc-p\">asdfsadfsadf</p>"
    },
    "scheduleManually": false,
    "startDate": null,
    "dueDate": null,
    "derivedStartDate": null,
    "derivedDueDate": null,
    "estimatedTime": null,
    "derivedEstimatedTime": null,
    "remainingTime": null,
    "derivedRemainingTime": null,
    "duration": null,
    "ignoreNonWorkingDays": false,
    "spentTime": "PT0S",
    "percentageDone": 0,
    "createdAt": "2024-02-11T23:09:11.222Z",
    "updatedAt": "2024-02-11T23:09:26.798Z",
    "laborCosts": "0.00 EUR",
    "materialCosts": "0.00 EUR",
    "overallCosts": "0.00 EUR",
    "_embedded": {
      "attachments": {
        "_type": "Collection",
        "total": 0,
        "count": 0,
        "_embedded": {
          "elements": []
        },
        "_links": {
          "self": {
            "href": "/api/v3/work_packages/38/attachments"
          }
        }
      },
      "relations": {
        "_type": "Collection",
        "total": 0,
        "count": 0,
        "_embedded": {
          "elements": []
        },
        "_links": {
          "self": {
            "href": "/api/v3/work_packages/38/relations"
          }
        }
      },
      "type": {
        "_type": "Type",
        "id": 1,
        "name": "Task",
        "color": "#1A67A3",
        "position": 1,
        "isDefault": true,
        "isMilestone": false,
        "createdAt": "2024-02-11T22:14:51.378Z",
        "updatedAt": "2024-02-11T22:14:51.378Z",
        "_links": {
          "self": {
            "href": "/api/v3/types/1",
            "title": "Task"
          }
        }
      },
      "priority": {
        "_type": "Priority",
        "id": 8,
        "name": "Normal",
        "position": 2,
        "color": "#74C0FC",
        "isDefault": true,
        "isActive": true,
        "_links": {
          "self": {
            "href": "/api/v3/priorities/8",
            "title": "Normal"
          }
        }
      },
      "project": {
        "_type": "Project",
        "id": 3,
        "identifier": "myfreshbrewedproject",
        "name": "MyFreshBrewedProject",
        "active": true,
        "public": true,
        "description": {
          "format": "markdown",
          "raw": "This is my first project in OpenProject",
          "html": "<p class=\"op-uc-p\">This is my first project in OpenProject</p>"
        },
        "createdAt": "2024-02-11T22:36:35.496Z",
        "updatedAt": "2024-02-11T22:36:35.525Z",
        "statusExplanation": {
          "format": "markdown",
          "raw": "My Status is here",
          "html": "<p class=\"op-uc-p\">My Status is here</p>"
        },
        "_links": {
          "self": {
            "href": "/api/v3/projects/3",
            "title": "MyFreshBrewedProject"
          },
          "createWorkPackage": {
            "href": "/api/v3/projects/3/work_packages/form",
            "method": "post"
          },
          "createWorkPackageImmediately": {
            "href": "/api/v3/projects/3/work_packages",
            "method": "post"
          },
          "workPackages": {
            "href": "/api/v3/projects/3/work_packages"
          },
          "storages": [],
          "categories": {
            "href": "/api/v3/projects/3/categories"
          },
          "versions": {
            "href": "/api/v3/projects/3/versions"
          },
          "memberships": {
            "href": "/api/v3/memberships?filters=%5B%7B%22project%22%3A%7B%22operator%22%3A%22%3D%22%2C%22values%22%3A%5B%223%22%5D%7D%7D%5D"
          },
          "types": {
            "href": "/api/v3/projects/3/types"
          },
          "update": {
            "href": "/api/v3/projects/3/form",
            "method": "post"
          },
          "updateImmediately": {
            "href": "/api/v3/projects/3",
            "method": "patch"
          },
          "delete": {
            "href": "/api/v3/projects/3",
            "method": "delete"
          },
          "schema": {
            "href": "/api/v3/projects/schema"
          },
          "ancestors": [],
          "projectStorages": {
            "href": "/api/v3/project_storages?filters=%5B%7B%22projectId%22%3A%7B%22operator%22%3A%22%3D%22%2C%22values%22%3A%5B%223%22%5D%7D%7D%5D"
          },
          "parent": {
            "href": null
          },
          "status": {
            "href": "/api/v3/project_statuses/on_track",
            "title": "On track"
          }
        }
      },
      "status": {
        "_type": "Status",
        "id": 1,
        "name": "New",
        "isClosed": false,
        "color": "#1098AD",
        "isDefault": true,
        "isReadonly": false,
        "defaultDoneRatio": null,
        "position": 1,
        "_links": {
          "self": {
            "href": "/api/v3/statuses/1",
            "title": "New"
          }
        }
      },
      "author": {
        "_type": "User",
        "id": 4,
        "name": "OpenProject Admin",
        "createdAt": "2024-02-11T22:14:53.515Z",
        "updatedAt": "2024-02-11T22:44:49.584Z",
        "login": "admin",
        "admin": true,
        "firstName": "OpenProject",
        "lastName": "Admin",
        "email": "admin@example.net",
        "avatar": "https://secure.gravatar.com/avatar/cb4f282fed12016bd18a879c1f27ff97?default=404&secure=true",
        "status": "active",
        "identityUrl": null,
        "language": "en",
        "_links": {
          "self": {
            "href": "/api/v3/users/4",
            "title": "OpenProject Admin"
          },
          "memberships": {
            "href": "/api/v3/memberships?filters=%5B%7B%22principal%22%3A%7B%22operator%22%3A%22%3D%22%2C%22values%22%3A%5B%224%22%5D%7D%7D%5D",
            "title": "Members"
          },
          "showUser": {
            "href": "/users/4",
            "type": "text/html"
          },
          "updateImmediately": {
            "href": "/api/v3/users/4",
            "title": "Update admin",
            "method": "patch"
          },
          "lock": {
            "href": "/api/v3/users/4/lock",
            "title": "Set lock on admin",
            "method": "post"
          },
          "delete": {
            "href": "/api/v3/users/4",
            "title": "Delete admin",
            "method": "delete"
          }
        }
      },
      "customActions": [],
      "costsByType": {
        "_type": "Collection",
        "total": 0,
        "count": 0,
        "_embedded": {
          "elements": []
        },
        "_links": {
          "self": {
            "href": "/api/v3/work_packages/38/summarized_costs_by_type"
          }
        }
      }
    },
    "_links": {
      "attachments": {
        "href": "/api/v3/work_packages/38/attachments"
      },
      "addAttachment": {
        "href": "/api/v3/work_packages/38/attachments",
        "method": "post"
      },
      "fileLinks": {
        "href": "/api/v3/work_packages/38/file_links"
      },
      "addFileLink": {
        "href": "/api/v3/work_packages/38/file_links",
        "method": "post"
      },
      "self": {
        "href": "/api/v3/work_packages/38",
        "title": "test"
      },
      "update": {
        "href": "/api/v3/work_packages/38/form",
        "method": "post"
      },
      "schema": {
        "href": "/api/v3/work_packages/schemas/3-1"
      },
      "updateImmediately": {
        "href": "/api/v3/work_packages/38",
        "method": "patch"
      },
      "delete": {
        "href": "/api/v3/work_packages/38",
        "method": "delete"
      },
      "logTime": {
        "href": "/api/v3/time_entries",
        "title": "Log time on test"
      },
      "move": {
        "href": "/work_packages/38/move/new",
        "type": "text/html",
        "title": "Move test"
      },
      "copy": {
        "href": "/work_packages/38/copy",
        "title": "Copy test"
      },
      "pdf": {
        "href": "/work_packages/38.pdf",
        "type": "application/pdf",
        "title": "Export as PDF"
      },
      "atom": {
        "href": "/work_packages/38.atom",
        "type": "application/rss+xml",
        "title": "Atom feed"
      },
      "availableRelationCandidates": {
        "href": "/api/v3/work_packages/38/available_relation_candidates",
        "title": "Potential work packages to relate to"
      },
      "customFields": {
        "href": "/projects/myfreshbrewedproject/settings/custom_fields",
        "type": "text/html",
        "title": "Custom fields"
      },
      "configureForm": {
        "href": "/types/1/edit?tab=form_configuration",
        "type": "text/html",
        "title": "Configure form"
      },
      "activities": {
        "href": "/api/v3/work_packages/38/activities"
      },
      "availableWatchers": {
        "href": "/api/v3/work_packages/38/available_watchers"
      },
      "relations": {
        "href": "/api/v3/work_packages/38/relations"
      },
      "revisions": {
        "href": "/api/v3/work_packages/38/revisions"
      },
      "watchers": {
        "href": "/api/v3/work_packages/38/watchers"
      },
      "addWatcher": {
        "href": "/api/v3/work_packages/38/watchers",
        "method": "post",
        "payload": {
          "user": {
            "href": "/api/v3/users/{user_id}"
          }
        },
        "templated": true
      },
      "removeWatcher": {
        "href": "/api/v3/work_packages/38/watchers/{user_id}",
        "method": "delete",
        "templated": true
      },
      "addRelation": {
        "href": "/api/v3/work_packages/38/relations",
        "method": "post",
        "title": "Add relation"
      },
      "addChild": {
        "href": "/api/v3/projects/myfreshbrewedproject/work_packages",
        "method": "post",
        "title": "Add child of test"
      },
      "changeParent": {
        "href": "/api/v3/work_packages/38",
        "method": "patch",
        "title": "Change parent of test"
      },
      "addComment": {
        "href": "/api/v3/work_packages/38/activities",
        "method": "post",
        "title": "Add comment"
      },
      "previewMarkup": {
        "href": "/api/v3/render/markdown?context=/api/v3/work_packages/38",
        "method": "post"
      },
      "timeEntries": {
        "href": "/api/v3/time_entries?filters=%5B%7B%22work_package_id%22%3A%7B%22operator%22%3A%22%3D%22%2C%22values%22%3A%5B%2238%22%5D%7D%7D%5D",
        "title": "Time entries"
      },
      "ancestors": [],
      "category": {
        "href": null
      },
      "type": {
        "href": "/api/v3/types/1",
        "title": "Task"
      },
      "priority": {
        "href": "/api/v3/priorities/8",
        "title": "Normal"
      },
      "project": {
        "href": "/api/v3/projects/3",
        "title": "MyFreshBrewedProject"
      },
      "status": {
        "href": "/api/v3/statuses/1",
        "title": "New"
      },
      "author": {
        "href": "/api/v3/users/4",
        "title": "OpenProject Admin"
      },
      "responsible": {
        "href": null
      },
      "assignee": {
        "href": null
      },
      "version": {
        "href": null
      },
      "parent": {
        "href": null,
        "title": null
      },
      "customActions": [],
      "logCosts": {
        "href": "/work_packages/38/cost_entries/new",
        "type": "text/html",
        "title": "Log costs on test"
      },
      "showCosts": {
        "href": "/projects/3/cost_reports?fields%5B%5D=WorkPackageId&operators%5BWorkPackageId%5D=%3D&set_filter=1&values%5BWorkPackageId%5D=38",
        "type": "text/html",
        "title": "Show cost entries"
      },
      "costsByType": {
        "href": "/api/v3/work_packages/38/summarized_costs_by_type"
      },
      "meetings": {
        "href": "/work_packages/38/tabs/meetings",
        "title": "meetings"
      },
      "github": {
        "href": "/work_packages/38/tabs/github",
        "title": "github"
      },
      "github_pull_requests": {
        "href": "/api/v3/work_packages/38/github_pull_requests",
        "title": "GitHub pull requests"
      },
      "convertBCF": {
        "href": "/api/bcf/2.1/projects/myfreshbrewedproject/topics",
        "title": "Convert to BCF",
        "payload": {
          "reference_links": [
            "/api/v3/work_packages/38"
          ]
        },
        "method": "post"
      }
    }
  }
}

REST

To use REST, I’ll need to create an API Token under my user’s settings

/content/images/2024/02/openproject-38.png

It will show it once

/content/images/2024/02/openproject-39.png

I can now use that in a Query to get all work packages

$ curl -X GET -H 'Content-Type: application/json' -H 'Accept: application/json' -u "apikey:6c67xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx1" https://openproject.freshbrewed.science/api/v3/wor
k_packages
{"_type":"WorkPackageCollection","total":35,"count":20,"pageSize":20,"offset":1,"_embedded":{"elements":[{"derivedStartDate":"2024-02-05","derivedDueDate":"2024-02-19","_type":"WorkPackage","id":2,"lockVersion":0,"subject":"Organize open source conference","description":{"format":"markdown","raw":"","html":""},"scheduleManually":false,"startDate":"2024-02-05","dueDate":"2024-02-19","estimatedTime":null,"derivedEstimatedTime":null,"remainingTime":null,"derivedRemainingTime":null,"duration":"P11D","ignoreNonWorkingDays":false,"percentageDone":0,"createdAt":"2024-02-11T22:14:54.140Z","updatedAt":"2024-02-11T22:14:54.565Z","_links":{"attachments":{"href":"/api/v3/work_packages/2/attachments"},"addAttachment":{"href":"/api/v3/work_packages/2/attachments","method":"post"},"
... snip ...

Using JQ, for example, we could pull the IDs and subjects

$ curl -X GET -H 'Content-Type: application/json' -H 'Accept: application/json' -u "apikey:6c67xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx1" https://openproject.freshbrewed.science/api/v3/work_packages | jq '._embedded.elements[] | "\(.id)  \(.subject)"'
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100  109k  100  109k    0     0   427k      0 --:--:-- --:--:-- --:--:--  427k
"2  Organize open source conference"
"3  Set date and location of conference"
"4  Send invitation to speakers"
"5  Contact sponsoring partners"
"6  Create sponsorship brochure and hand-outs"
"7  Invite attendees to conference"
"8  Setup conference website"
"9  Conference"
"10  Follow-up tasks"
"11  Upload presentations to website"
"12  Party for conference supporters :-)"
"13  End of project"
"14  New login screen"
"15  Password reset does not send email"
"16  New website"
"17  Newsletter registration form"
"18  Implement product tour"
"19  New landing page"
"20  Create wireframes for new landing page"
"21  Contact form"

To create an issue, we can POST a JSON body. Note: Project is a required field and is under “_links”.

$ cat openproject_body.json
{
        "subject": "Fresh New Package",
        "Description": {
                "format": "markdown",
                "raw": "howdy howdy",
                "html": "<p>howdy Howdy</p>"
        },
        "scheduleManually": false,
        "startDate": "2024-02-12",
        "dueDate": "2024-03-12",
        "estimatedTime": null,
        "_links": {
          "project": {
            "href": "/api/v3/projects/3",
            "title": "MyFreshBrewedProject"
          }
        }
}


$ curl -X POST -H 'Content-Type: application/json' -H 'Accept: application/json' -u "apikey:6c67xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx1" https://openproject.freshbrewed.science/api/v3/work_packages -d @openproject_body.json
{"derivedStartDate":null,"derivedDueDate":null,"spentTime":"PT0S","laborCosts":"0.00 EUR","materialCosts":"0.00 EUR","overallCosts":"0.00 EUR","_embedded":{"attachments":{"_type":"Collection","total":0,"count":0,"_embedded":{"elements":[]},"_links":{"self":{"href":"/api/v3/work_packages/40/attachments"}}},"relations":{"_type":"Collection","total":0,"count":0,"_embedded":{"elements":[]},"_links":{"self":{"href":"/api/v3/work_packages/40/relations"}}},"type":{"_type":"Type","id":1,"name":"Task","color":"#1A67A3","position":1,"isDefault":true,"isMilestone":false,"createdAt":"2024-02-11T22:14:51.378Z","updatedAt":"2024-02-11T22:14:51.378Z","_links":{"self":{"href":"/api/v3/types/1","title":"Task"}}},"priority":{"_type":"Priority","id":8,"name":"Normal","position":2,"color":"#74C0FC","isDefault":true,"isActive":true,"_links":{"self":{"href":"/api/v3/priorities/8","title":"Normal"}}},"project":{"_type":"Project","id":3,"identifier":"myfreshbrewedproject","name":"MyFreshBrewedProject","active":true,"public":true,"description":{"format":"markdown","raw":"This is my first project in OpenProject","html":"<p class=\"op-uc-p\">This is my first project in OpenProject</p>"},"createdAt":"2024-02-11T22:36:35.496Z","updatedAt":"2024-02-11T22:36:35.525Z","statusExplanation":{"format":"markdown","raw":"My Status is here","html":"<p class=\"op-uc-p\">My Status is here</p>"},"_links":{"self":{"href":"/api/v3/projects/3","title":"MyFreshBrewedProject"},"createWorkPackage":{"href":"/api/v3/projects/3/work_packages/form","method":"post"},"createWorkPackageImmediately":{"href":"/api/v3/projects/3/work_packages","method":"post"},"workPackages":{"href":"/api/v3/projects/3/work_packages"},"categories":{"href":"/api/v3/projects/3/categories"},"versions":{"href":"/api/v3/projects/3/versions"},"memberships":{"href":"/api/v3/memberships?filters=%5B%7B%22project%22%3A%7B%22operator%22%3A%22%3D%22%2C%22values%22%3A%5B%223%22%5D%7D%7D%5D"},"types":{"href":"/api/v3/projects/3/types"},"update":{"href":"/api/v3/projects/3/form","method":"post"},"updateImmediately":{"href":"/api/v3/projects/3","method":"patch"},"delete":{"href":"/api/v3/projects/3","method":"delete"},"schema":{"href":"/api/v3/projects/schema"},"status":{"href":"/api/v3/project_statuses/on_track","title":"On track"},"ancestors":[],"projectStorages":{"href":"/api/v3/project_storages?filters=%5B%7B%22projectId%22%3A%7B%22operator%22%3A%22%3D%22%2C%22values%22%3A%5B%223%22%5D%7D%7D%5D"},"parent":{"href":null}}},"status":{"_type":"Status","id":1,"name":"New","isClosed":false,"color":"#1098AD","isDefault":true,"isReadonly":false,"defaultDoneRatio":null,"position":1,"_links":{"self":{"href":"/api/v3/statuses/1","title":"New"}}},"author":{"_type":"User","id":4,"name":"OpenProject Admin","createdAt":"2024-02-11T22:14:53.515Z","updatedAt":"2024-02-11T22:44:49.584Z","login":"admin","admin":true,"firstName":"OpenProject","lastName":"Admin","email":"admin@example.net","avatar":"https://secure.gravatar.com/avatar/cb4f282fed12016bd18a879c1f27ff97?default=404&secure=true","status":"active","identityUrl":null,"language":"en","_links":{"self":{"href":"/api/v3/users/4","title":"OpenProject Admin"},"memberships":{"href":"/api/v3/memberships?filters=%5B%7B%22principal%22%3A%7B%22operator%22%3A%22%3D%22%2C%22values%22%3A%5B%224%22%5D%7D%7D%5D","title":"Members"},"showUser":{"href":"/users/4","type":"text/html"},"updateImmediately":{"href":"/api/v3/users/4","title":"Update admin","method":"patch"},"lock":{"href":"/api/v3/users/4/lock","title":"Set lock on admin","method":"post"}}},"customActions":[],"costsByType":{"_type":"Collection","total":0,"count":0,"_embedded":{"elements":[]},"_links":{"self":{"href":"/api/v3/work_packages/40/summarized_costs_by_type"}}}},"_type":"WorkPackage","id":40,"lockVersion":0,"subject":"Fresh New Package","description":{"format":"markdown","raw":"","html":""},"scheduleManually":false,"startDate":"2024-02-12","dueDate":"2024-03-12","estimatedTime":null,"derivedEstimatedTime":null,"remainingTime":null,"derivedRemainingTime":null,"duration":"P22D","ignoreNonWorkingDays":false,"percentageDone":0,"createdAt":"2024-02-12T12:32:56.491Z","updatedAt":"2024-02-12T12:32:56.491Z","_links":{"attachments":{"href":"/api/v3/work_packages/40/attachments"},"addAttachment":{"href":"/api/v3/work_packages/40/attachments","method":"post"},"update":{"href":"/api/v3/work_packages/40/form","method":"post"},"schema":{"href":"/api/v3/work_packages/schemas/3-1"},"updateImmediately":{"href":"/api/v3/work_packages/40","method":"patch"},"delete":{"href":"/api/v3/work_packages/40","method":"delete"},"logTime":{"href":"/api/v3/time_entries","title":"Log time on Fresh New Package"},"move":{"href":"/work_packages/40/move/new","type":"text/html","title":"Move Fresh New Package"},"copy":{"href":"/work_packages/40/copy","title":"Copy Fresh New Package"},"pdf":{"href":"/work_packages/40.pdf","type":"application/pdf","title":"Export as PDF"},"atom":{"href":"/work_packages/40.atom","type":"application/rss+xml","title":"Atom feed"},"availableRelationCandidates":{"href":"/api/v3/work_packages/40/available_relation_candidates","title":"Potential work packages to relate to"},"customFields":{"href":"/projects/myfreshbrewedproject/settings/custom_fields","type":"text/html","title":"Custom fields"},"configureForm":{"href":"/types/1/edit?tab=form_configuration","type":"text/html","title":"Configure form"},"activities":{"href":"/api/v3/work_packages/40/activities"},"availableWatchers":{"href":"/api/v3/work_packages/40/available_watchers"},"relations":{"href":"/api/v3/work_packages/40/relations"},"watchers":{"href":"/api/v3/work_packages/40/watchers"},"addWatcher":{"href":"/api/v3/work_packages/40/watchers","method":"post","payload":{"user":{"href":"/api/v3/users/{user_id}"}},"templated":true},"removeWatcher":{"href":"/api/v3/work_packages/40/watchers/{user_id}","method":"delete","templated":true},"addRelation":{"href":"/api/v3/work_packages/40/relations","method":"post","title":"Add relation"},"addChild":{"href":"/api/v3/projects/myfreshbrewedproject/work_packages","method":"post","title":"Add child of Fresh New Package"},"changeParent":{"href":"/api/v3/work_packages/40","method":"patch","title":"Change parent of Fresh New Package"},"addComment":{"href":"/api/v3/work_packages/40/activities","method":"post","title":"Add comment"},"previewMarkup":{"href":"/api/v3/render/markdown?context=/api/v3/work_packages/40","method":"post"},"timeEntries":{"href":"/api/v3/time_entries?filters=%5B%7B%22work_package_id%22%3A%7B%22operator%22%3A%22%3D%22%2C%22values%22%3A%5B%2240%22%5D%7D%7D%5D","title":"Time entries"},"category":{"href":null},"type":{"href":"/api/v3/types/1","title":"Task"},"priority":{"href":"/api/v3/priorities/8","title":"Normal"},"project":{"href":"/api/v3/projects/3","title":"MyFreshBrewedProject"},"status":{"href":"/api/v3/statuses/1","title":"New"},"author":{"href":"/api/v3/users/4","title":"OpenProject Admin"},"responsible":{"href":null},"assignee":{"href":null},"version":{"href":null},"logCosts":{"href":"/work_packages/40/cost_entries/new","type":"text/html","title":"Log costs on Fresh New Package"},"showCosts":{"href":"/projects/3/cost_reports?fields%5B%5D=WorkPackageId&operators%5BWorkPackageId%5D=%3D&set_filter=1&values%5BWorkPackageId%5D=40","type":"text/html","title":"Show cost entries"},"costsByType":{"href":"/api/v3/work_packages/40/summarized_costs_by_type"},"meetings":{"href":"/work_packages/40/tabs/meetings","title":"meetings"},"github_pull_requests":{"href":"/api/v3/work_packages/40/github_pull_requests","title":"GitHub pull requests"},"self":{"href":"/api/v3/work_packages/40","title":"Fresh New Package"},"unwatch":{"href":"/api/v3/work_packages/40/watchers/4","method":"delete"},"ancestors":[],"parent":{"href":null,"title":null},"customActions":[]}}

I can now see the new work package in my list

/content/images/2024/02/openproject-40.png

While the subject looks right and the due dates were set, the Description was not

I typed in a field and then fetched with the API to see my mistake, I should have used lowercase for description


  "description": {
    "format": "markdown",
    "raw": "This is me entering it",
    "html": "<p class=\"op-uc-p\">This is me entering it</p>"
  },

Let’s try one more time

$ cat openproject_body.json
{
        "subject": "Fresh New Package 2",
        "description": {
            "format": "markdown",
            "raw": "Trying again",
            "html": "<p>trying again</p>"
        },
        "scheduleManually": false,
        "startDate": "2024-02-14",
        "dueDate": "2024-03-14",
        "estimatedTime": null,
        "_links": {
          "project": {
            "href": "/api/v3/projects/3",
            "title": "MyFreshBrewedProject"
          }
        }
}

$ curl -X POST -H 'Content-Type: application/json' -H 'Accept: application/json' -u "apikey:6c67xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx1" https://openproject.freshbrewed.science/api/v3/work_packages -d @openproject_body.json
{"derivedStartDate":null,"derivedDueDate":null,"spentTime":"PT0S","laborCosts":"0.00 EUR","materialCosts":"0.00 EUR","overallCosts":"0.00 EUR","_embedded":{"attachments":{"_type":"Collection","total":0,"count":0,"_embedded":{"elements":[]},"_links":{"self":{"href":"/api/... snip ...

That worked (and it picked the casing from “raw” i might add):

/content/images/2024/02/openproject-42.png

Addendum

I was trying to create some kind of art header for the article so I looked up their Berlin address on Google Maps. They are in the same city block as “The Disgusting Food Museum” with such wonderfully German reviews “Its good, but it’s terrible”

/content/images/2024/02/openproject-43.png

Summary

Today we covered the installation of OpenProject in Kubernetes and Docker (then Exposed through Kubernetes). We covered setup and initial usage, such as adding Work Packages to a board.

We looked into the Enterprise costs (and Advertisements) as well as subscription options. Lastly, we touched on webhooks and the REST API to reading and creating issues.

I actually have so much to cover it warranted a part two where we will dig into the SaaS offering, custom fields, time management, costs, meetings and more. Expect that in the coming days

Kubernetes Docker Openproject PjM

Have something to add? Feedback? Try our new forums

Isaac Johnson

Isaac Johnson

Cloud Solutions Architect

Isaac is a CSA and DevOps engineer who focuses on cloud migrations and devops processes. He also is a dad to three wonderful daughters (hence the references to Princess King sprinkled throughout the blog).

Theme built by C.S. Rhymes