How to prevent API from duplicating entries, not possible to be idempotent?

1. The problem I’m having:

Using the POST/PUT /config/path appends the object instead of replacing

2. Error messages and/or full log output:

No error log

Caddy starts out with a nearly blank config

{
  "apps": {
    "http": {
      "servers": {
        "homelab": {
          "listen": [
            ":443"
          ],
          "routes": [],
          "tls_connection_policies": []
        }
      }
    },
    "tls": {
      "certificates": {
        "load_files": [
          {
            "certificate": "/certificates/ghastlylab.io.crt",
            "key": "/certificates/ghastlylab.io.key",
            "tags": [
              "cert0"
            ]
          }
        ]
      }
    }
  }
}

Then I add a tls_connection_policy

curl -X POST "localhost:2019/config/apps/http/servers/homelab/tls_connection_policies/" \
        -H "Content-Type: application/json" \
        -d '
  {
    "certificate_selection": {
      "any_tag": [
        "cert0"
      ]
    },
    "match": {
      "sni": [
        "pdf.ghastlylab.io"
      ]
    }
  }'

Config after:

{
  "apps": {
    "http": {
      "servers": {
        "homelab": {
          "listen": [
            ":443"
          ],
          "routes": [],
          "tls_connection_policies": [
            {
              "certificate_selection": {
                "any_tag": [
                  "cert0"
                ]
              },
              "match": {
                "sni": [
                  "pdf.ghastlylab.io"
                ]
              }
            }
          ]
        }
      }
    },
    "tls": {
      "certificates": {
        "load_files": [
          {
            "certificate": "/certificates/ghastlylab.io.crt",
            "key": "/certificates/ghastlylab.io.key",
            "tags": [
              "cert0"
            ]
          }
        ]
      }
    }
  }
}

Running my ansible playbook again results in the same entry being appended.

{
  "apps": {
    "http": {
      "servers": {
        "homelab": {
          "listen": [
            ":443"
          ],
          "routes": [],
          "tls_connection_policies": [
            {
              "certificate_selection": {
                "any_tag": [
                  "cert0"
                ]
              },
              "match": {
                "sni": [
                  "pdf.ghastlylab.io"
                ]
              }
            },
            {
              "certificate_selection": {
                "any_tag": [
                  "cert0"
                ]
              },
              "match": {
                "sni": [
                  "pdf.ghastlylab.io"
                ]
              }
            }
          ]
        }
      }
    },
    "tls": {
      "certificates": {
        "load_files": [
          {
            "certificate": "/certificates/ghastlylab.io.crt",
            "key": "/certificates/ghastlylab.io.key",
            "tags": [
              "cert0"
            ]
          }
        ]
      }
    }
  }
}

After reading the PUT and POST config/[path] API docs I’m not sure if there is a way to create objects with an API call and do nothing with the same API call if the object already exists.

Tagging with @id also didn’t work. What I noticed is that if I insert two policies with the same tag and do a DELETE to the tag only one entry gets removed and I have to make two DELETE calls to completly remove them.

I noticed this behaviour first on the tls_connection_policies, which I could circumvent, but the same problem happens on the route array as well. Running the same API call results in two identical routes which borks the config and the route/host isn’t working anymore

Is it possible to keep the config idempotent with the API?

I believe the section Concurrent config changes is useful for you:

Thanks for the reply, I’ve already found a way to be consistent with the configuration using tags

Could you share it for the sake of future generations?

1 Like

Sure, the basic gist was that I deploy services with ansible and I wanted to configure DNS and reverse proxy with their respective API.

For caddy the problem was that even while the configuration was valid using POST with an object was appending the same routes/tls_connection_policies to the existing configuration when running the playbook during redeployment, which happens for updates/debugging/etc. The result was duplicated entries for the same service which would also prevent them from working.

While I didnt solve the initial problem with the POST/PUT API calls I figured that the @id tagging would suffice.

Caddy starts with a basic empty template:

{
    "apps": {
      "http": {
        "servers": {
          "srv0": {
            "listen": [
              ":443"
            ],
            "routes": [],
            "tls_connection_policies": []
          }
        }
      },
      "tls": {
        "certificates": {
          "load_files": [
            {
              "certificate": "/certificates/cert.crt",
              "key": "/certificates/privkeykey",
              "tags": [
                "cert0"
              ]
            }
          ]
        }
      }
    }
  }

Then my playbook for each service makes two API calls, one for the route and for the the tls_connection_policies

curl -X POST http://localhost:2019/config/apps/http/servers/srv0/tls_connection_policies/

{   
    "@id": "service1-policy",
    "certificate_selection": {
      "any_tag": [
        "cert0"
      ]
    },
    "match": {
      "sni": [
        "service1.domain.io"
      ]
    }
  }

curl -X POST http://localhost:2019/config/apps/http/servers/srv0/routes/

{
    "@id": "service1",
    "handle": [
      {
        "handler": "subroute",
        "routes": [
          {
            "handle": [
              {
                "handler": "reverse_proxy",
                "transport": {
                  "protocol": "http",
                  "tls": {
                    "insecure_skip_verify": true
                  }
                },
                "upstreams": [
                  {
                    "dial": "XX.XX.XX.XX:XXXX"
                  }
                ]
              }
            ]
          }
        ]
      }
    ],
    "match": [
      {
        "host": [
          "service1.domain.io"
        ]
      }
    ],
    "terminal": true
  }

When the playbook runs before it makes the two API calls it would run this task

- name: Delete route/policy from Caddy
  delegate_to: caddy
  ansible.builtin.uri:
    url: "http://localhost:2019/id/{{ item.line }}"
    method: DELETE
    status_code:
      - 200
      - 404
    headers:
      Content-Type: application/json
  loop:
    - { line: "service1-policy" }
    - { line: "service1" }

If the configuration doesnt exist the API returns a 404 which the task accepts as valid return code and then creates the route/tls_connection_policies.
If the tags exist they, and the configuration, will be deleted and re-created.

You could make some QoL changes to the behavior, but for my use case it suffices for now.

3 Likes

Thanks for sharing your solution!!

Just to explain the problem a little further:

In your case, you’re asking for idempotency for appending an object (POST requests to a list). Append, by definition, isn’t idempotent. And conceptually, it can’t really be idempotent in a general sense.

You would think: “Idempotent append should only append if the element has not already been appended.” But what if another element was appended after it, and now it is not at the end of the list anymore?

Plus, you have to define equality. On some values this is obvious, on others it is not. Equality has more of a semantic meaning than a primitive one when it comes to configuration.

So, POSTing the entire list like you’re doing is probably best.

4 Likes