Skip to content

[feat] add coolify-node entity and example #37

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions coolify/MANIFEST
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
REPO monk-coolify
LOAD node-entity.yaml
RESOURCES node-sync.js
26 changes: 26 additions & 0 deletions coolify/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Coolify example

Creates coolify selft-hosted instance on selected provider using Monk.
Another instance is joined to the first one using api with token auth.

## Usage example

See example.yaml file

First, need to set secrets for admin user password:
Email has to be real and pass requirements and password has to pass the requirements:
https://coolify.io/docs/knowledge-base/create-root-user-with-env

```bash
monk load MANIFEST
monk load example.yaml

monk secrets add -r coolify-example/root-node root-email='[email protected]'
monk secrets add -r coolify-example/root-node root-password='2ekUr_fEgyi'

monk run coolify-example/stack
```

Then, open the browser and go to the url of the first instance and use email/password from above.

Theoretically, in place of root-node, apiUrl and token can be used to connect to cloud coolify instance (may need some adjustments).
58 changes: 58 additions & 0 deletions coolify/example.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
namespace: coolify-example

# root node where we install coolify
root-node:
defines: coolify/node
provider: azure
name: root-node
region: polandcentral
instance: Standard_B2s
os: ubuntu2404
diskSize: 50
diskType: hdd
rootUser: # admin GUI user
username: root
email: <- secret("root-email")
passwordSecret: root-password
tokenSecret: coolify-token # secret where to save PAT for api
publicPorts: # exposed ports
- "22/tcp"
- "8000/tcp"
- "6001/tcp"
- "6002/tcp"
permitted-secrets:
coolify-token: true
services:
data:
protocol: custom

join-node:
defines: coolify/node
provider: azure
name: join-node
region: polandcentral
instance: Standard_B2s
os: ubuntu2404
diskSize: 50
diskType: hdd
tokenSecret: coolify-token
apiUrl: <- connection-target("root") entity-state get-member("apiUrl")
publicPorts: # exposed ports
- "22/tcp"
permitted-secrets:
coolify-token: true
connections:
root:
runnable: coolify-example/root-node
service: data
depends:
wait-for:
runnables:
- coolify-example/root-node
timeout: 600

stack:
defines: process-group
runnable-list:
- coolify-example/root-node
- coolify-example/join-node
46 changes: 46 additions & 0 deletions coolify/node-entity.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
namespace: coolify

node:
defines: entity
schema:
required: ["provider", "name", "region", "instance"]
provider:
type: string
name:
type: string
region:
type: string
instance:
type: string
os:
enum:
- ubuntu2404
- debian12
diskSize:
type: integer
diskType:
type: string
tokenSecret:
type: string
apiUrl: # url to the api of the root node
type: string
rootUser: # user to configure for the root node
type: object
properties:
username:
type: string
email:
type: string
passwordSecret:
type: string
publicPorts:
type: array
items:
type: string
lifecycle:
sync: <<< node-sync.js
checks:
readiness:
period: 15
initialDelay: 5
attempts: 40
247 changes: 247 additions & 0 deletions coolify/node-sync.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,247 @@
const cm = require("cloud/manager");
const secret = require("secret");
const crypto = require("crypto");
const http = require("http");

function getNode(def) {
const manager = cm.init(def.provider);

const n = manager.getNode(def.name, def.region);

return n;
}

function createNode(def) {
const manager = cm.init(def.provider);

const opts = {};

if (!def.apiUrl) { // if no apiUrl is provided, we assume this is root node
const password = secret.get(def.rootUser.passwordSecret).replace("\"", "\\\"");

const token = secret.randString(16);
secret.set(def.tokenSecret, "1|" + token);

const tokenHash = crypto.sha256(token);

opts.startupScript = `#!/usr/bin/sh
set -e
wget https://cdn.coollabs.io/coolify/install.sh -O /tmp/install.sh
chmod +x /tmp/install.sh
ROOT_USERNAME="${def.rootUser.username}" \
ROOT_USER_EMAIL="${def.rootUser.email}" \
ROOT_USER_PASSWORD="${password}" \
/tmp/install.sh
docker exec coolify-db psql -U coolify -c "UPDATE instance_settings set is_api_enabled=true"
docker exec coolify-db psql -U coolify -c "INSERT INTO personal_access_tokens \
(tokenable_type, tokenable_id, name, token, team_id, abilities, created_at) \
VALUES ('App\\Models\\User', 0, 'Monk token', '${tokenHash}', 0, '[\\"root\\"]', NOW())"
`;
} else { // if apiUrl is provided, we add root node public key to allow ssh access
const token = secret.get(def.tokenSecret);
const privKey = getPrivateKey(def.apiUrl, token);
opts.sshPublicKey = privKey.public_key;
}

if (def.os) {
opts.os = def.os;
}
if (def.diskSize) {
opts.diskSize = def.diskSize;
}
if (def.diskType) {
opts.diskType = def.diskType;
}

const n = manager.createNode(def.name, def.region, def.instance, opts);

if (def.publicPorts) {
manager.addPorts(def.name, def.region, def.publicPorts);
}

let state = {
id: n.id,
name: def.name,
region: def.region,
ip: n.ip,
publicPorts: def.publicPorts
}
if (!def.apiUrl) {
state.apiUrl = "http://" + n.ip + ":8000";
}

return state
}

function updateNode(def, state) {
const manager = cm.init(def.provider);

if (def.publicPorts !== state.publicPorts) {
manager.addPorts(def.name, def.region, def.publicPorts);
}

state.publicPorts = def.publicPorts;

return state;
}

function deleteNode(def, state) {
const manager = cm.init(def.provider);

return manager.deleteNode(def.name, def.region);
}

function getPrivateKey(apiUrl, token) {
let res = http.get(apiUrl + "/api/v1/security/keys",
{
headers: {
"authorization": "Bearer " + token,
"content-type": "application/json"
}
});
if (res.error) {
throw new Error(res.error + ", body " + res.body);
}

if (res.statusCode !== 200) {
throw new Error("Error getting SSH public key: " + res.statusCode + ", body " + res.body);
}

resArr = JSON.parse(res.body);
for (let i = 0; i < resArr.length; i++) {
if (resArr[i].id === 0) {
return resArr[i];
}
}

throw new Error("No private key found");
}

function getServerByIP(apiUrl, token, ip) {
let res = http.get(apiUrl + "/api/v1/servers",
{
headers: {
"authorization": "Bearer " + token,
"content-type": "application/json"
}
});
if (res.error) {
throw new Error(res.error + ", body " + res.body);
}
if (res.statusCode !== 200) {
throw new Error("Error getting servers: " + res.statusCode + ", body " + res.body);
}
let resArr = JSON.parse(res.body);
for (let i = 0; i < resArr.length; i++) {
if (resArr[i].ip === ip) {
return resArr[i];
}
}

return null;
}

function joinRoot(def, state) {
if (state.uuid) {
return state;
}

const token = secret.get(def.tokenSecret);

let uuid = "";
const srv = getServerByIP(def.apiUrl, token, state.ip);
if (srv !== null) {
uuid = srv.uuid;
} else {
const privKey = getPrivateKey(def.apiUrl, token);

const body = {
name: def.name,
ip: state.ip,
port: 22,
user: "monkd",
private_key_uuid: privKey.uuid,
};

let res = http.post(def.apiUrl + "/api/v1/servers",
{
headers: {
"authorization": "Bearer " + token,
"content-type": "application/json"
},
body: JSON.stringify(body)
});
if (res.error) {
throw new Error(res.error + ", body " + res.body);
}

if (res.statusCode !== 200) {
throw new Error("Error getting SSH public key: " + res.statusCode + ", body " + res.body);
}
let resObj = JSON.parse(res.body);

uuid = resObj.uuid;
}

let resVal = http.get(def.apiUrl + "/api/v1/servers/" + uuid + "/validate",
{
headers: {
"authorization": "Bearer " + token,
"content-type": "application/json"
}
});
if (resVal.error) {
throw new Error(res.error + ", body " + res.body);
}

if (resVal.statusCode >= 400) {
throw new Error("Error validating node: " + resVal.statusCode + ", body " + resVal.body);
}

state.uuid = uuid;

return state;
}

function main(def, state, ctx) {
if (ctx.action === "update" && !state.name) {
ctx.action = "create";
}
switch (ctx.action) {
case "create":
state = createNode(def)
break;
case "update":
state = updateNode(def, state)
break;
case "purge":
if (state.name) {
deleteNode(def, state);
}
break;
case "check-readiness":
if (!def.apiUrl) {
try {
const token = secret.get(def.tokenSecret);
const privKey = getPrivateKey(state.apiUrl, token);
state.sshPublicKey = privKey.public_key;
} catch (e) {
console.log("Error getting SSH public key: " + e.message);
throw e;
}
} else {
try {
state = joinRoot(def, state);
} catch (e) {
console.log("Error joining root: " + e.message);
throw e;
}
}
break
default:
// no action defined
return;
}

return state;
}