Skip to content

Commit 3537ec5

Browse files
committed
publish
0 parents  commit 3537ec5

29 files changed

+1185
-0
lines changed

.dockerignore

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
__pychache__/
2+
instance
3+
tests
4+
venv
5+
Dockerfile-python
6+
Dockerfile-alpine
7+
create-image.sh
8+
test_requirements.txt
9+
skaffold.yaml
10+
k8s

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
__pycache__/

.gitlab-ci.yml

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
build_image:
2+
stage: build
3+
only:
4+
- tags
5+
image:
6+
name: docker:stable
7+
services:
8+
- name: docker:dind
9+
entrypoint: ["env", "-u", "DOCKER_HOST"]
10+
command: ["dockerd-entrypoint.sh"]
11+
variables:
12+
DOCKER_HOST: tcp://docker:2375/
13+
DOCKER_DRIVER: overlay2
14+
# See https://github.com/docker-library/docker/pull/166
15+
DOCKER_TLS_CERTDIR: ""
16+
script:
17+
- apk add git bash
18+
- echo $CI_REGISTRY_PASSWORD | docker login -u $CI_REGISTRY_USER $CI_REGISTRY --password-stdin
19+
- $CI_PROJECT_DIR/create-image.sh $CI_COMMIT_TAG
20+
- docker push hub.semafor.ch/semafor/config-controller:latest
21+
- docker push hub.semafor.ch/semafor/config-controller:$CI_COMMIT_TAG

Dockerfile

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
FROM alpine:3.17.3
2+
3+
ARG REVISION
4+
5+
RUN apk add python3 py-pip
6+
7+
ENV VIRTUAL_ENV=/opt/venv
8+
RUN python3 -m venv $VIRTUAL_ENV
9+
ENV PATH="$VIRTUAL_ENV/bin:$PATH"
10+
11+
WORKDIR /app
12+
COPY . .
13+
# install the library dependencies for this application
14+
RUN pip3 install --no-cache-dir -r requirements.txt && opentelemetry-bootstrap -a install && \
15+
[ -z "$REVISION" ] ||echo "$REVISION" > vcs.info
16+
17+
ENV FLASK_APP="controller"
18+
ENV SERVICE_PORT=3333
19+
ENV LABEL_KEY="app"
20+
ENV CONFIG_DIR="/etc/config-controller"
21+
ENV MIN_NUM_IDLING_CONTAINERS=1
22+
23+
EXPOSE ${SERVICE_PORT}
24+
#
25+
CMD [ "/app/entrypoint.sh" ]

LICENSE

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
Copyright 2025 SEMAFOR Informatik & Energie AG, Basel
2+
3+
Licensed under the Apache License, Version 2.0 (the "License");
4+
you may not use this file except in compliance with the License.
5+
You may obtain a copy of the License at
6+
7+
https://www.apache.org/licenses/LICENSE-2.0
8+
9+
Unless required by applicable law or agreed to in writing, software
10+
distributed under the License is distributed on an "AS IS" BASIS,
11+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
See the License for the specific language governing permissions and
13+
limitations under the License.

Procfile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
web: FLASK_APP=controller python3 -m flask run --host=0.0.0.0 --port=$PORT

config.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import os
2+
import pathlib
3+
4+
name = os.environ.get('FLASK_APP') or 'config-controller'
5+
6+
7+
def vcs_info():
8+
g = pathlib.Path(__file__).parent / 'vcs.info'
9+
if g.exists():
10+
return g.read_text().strip()
11+
import subprocess
12+
try:
13+
p = subprocess.run(['git', 'describe'], capture_output=True)
14+
if p.returncode == 0:
15+
return p.stdout.decode().strip()
16+
except:
17+
pass
18+
return name.strip()
19+
20+
21+
class Config(object):
22+
LABEL_KEY = os.environ.get('LABEL_KEY') or 'app'
23+
MIN_NUM_IDLING_CONTAINERS = int(
24+
os.environ.get('MIN_NUM_IDLING_CONTAINERS') or 1)
25+
BASE_DIR = os.environ.get('BASE_DIR')
26+
CONFIG_DIR = os.environ.get('CONFIG_DIR') or '/etc/config-controller'
27+
VCS_INFO = vcs_info()

controller/__init__.py

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import platform
2+
import logging
3+
import os
4+
import flask
5+
import werkzeug
6+
# from flask_session import Session
7+
import pathlib
8+
import config
9+
import atexit
10+
11+
logging.basicConfig(level=logging.INFO,
12+
format='%(asctime)s %(message)s')
13+
14+
kube = None
15+
dock = None
16+
17+
18+
def create_app(test_config=None):
19+
global kube
20+
global dock
21+
22+
# create and configure the app
23+
app = flask.Flask(__name__, instance_relative_config=True)
24+
app.config.from_object(config.Config())
25+
26+
if test_config is None:
27+
# load the instance config, if it exists, when not testing
28+
app.config.from_pyfile('config.py', silent=True)
29+
if pathlib.Path('/var/run/docker.sock').is_socket() and os.environ.get('DOCKER'):
30+
import controller.docker_api
31+
dock = controller.docker_api.DockerApi(app.config['BASE_DIR'])
32+
from controller.routes import get_templates, create_instance
33+
if app.config['MIN_NUM_IDLING_CONTAINERS'] > 0:
34+
for template in get_templates(app.config['CONFIG_DIR']):
35+
create_instance(app.config['MIN_NUM_IDLING_CONTAINERS'],
36+
app.config['CONFIG_DIR'], template, "0",
37+
app.logger)
38+
else:
39+
try:
40+
import controller.kubernetes_api
41+
controller.kubernetes_api.load_config()
42+
kube = controller.kubernetes_api.KubernetesApi()
43+
except:
44+
kube = None
45+
app.logger.warning("Kubernetes Client", exc_info=True)
46+
47+
else:
48+
# load the test config if passed in
49+
app.config.from_mapping(test_config)
50+
kube = test_config['KUBE']
51+
dock = test_config['DOCK']
52+
53+
app.logger.info("{'name': '%s', 'version': '%s'}",
54+
config.name, app.config['VCS_INFO'])
55+
# ensure the instance folder exists
56+
try:
57+
os.makedirs(app.instance_path)
58+
except OSError:
59+
pass
60+
61+
@app.errorhandler(werkzeug.exceptions.BadRequest)
62+
def badrequest(error):
63+
return (flask.jsonify(dict(status='error', msg='bad request')),
64+
werkzeug.exceptions.BadRequest)
65+
66+
@app.errorhandler(404)
67+
def not_found_error(error):
68+
return flask.jsonify(dict(status='error', msg='not found')), 404
69+
70+
@app.errorhandler(500)
71+
def internal_error(error):
72+
return flask.jsonify(dict(status='error', msg='internal error')), 500
73+
74+
@app.route('/info', methods=['GET'])
75+
def get_info():
76+
"""return info"""
77+
info = dict(status='UP', hostname=platform.node(),
78+
config_dir=app.config['CONFIG_DIR'],
79+
rev=app.config['VCS_INFO'])
80+
return flask.jsonify(info)
81+
82+
from controller.routes import bp
83+
app.register_blueprint(bp)
84+
85+
return app
86+
87+
# Session(app)
88+
89+
# register exit function
90+
91+
92+
def delete_containers():
93+
conf = config.Config()
94+
from .routes import delete_all_containers
95+
delete_all_containers(conf.CONFIG_DIR, conf.LABEL_KEY)
96+
97+
98+
atexit.register(delete_containers)

controller/docker_api.py

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
"""
2+
lists, creates and deletes containers with docker API
3+
4+
https://docker-py.readthedocs.io
5+
6+
Note: Labels on images, containers, local daemons, volumes, and networks are
7+
static for the lifetime of the object. To change these labels you must
8+
recreate the object. (https://docs.docker.com/config/labels-custom-metadata)
9+
"""
10+
import docker
11+
import logging
12+
import uuid
13+
import time
14+
import os
15+
16+
logger = logging.getLogger(__name__)
17+
18+
19+
def get_ipaddr(c):
20+
"""return ip address of container"""
21+
for k in c.attrs['NetworkSettings']['Networks']:
22+
if 'IPAddress' in c.attrs['NetworkSettings']['Networks'][k]:
23+
return c.attrs['NetworkSettings']['Networks'][k]['IPAddress']
24+
return ''
25+
26+
27+
class DockerApi(object):
28+
29+
def __init__(self, basedir=''):
30+
self.docker_client = docker.DockerClient(
31+
base_url='unix://var/run/docker.sock')
32+
self.docker_network = None
33+
self.basedir = basedir if basedir else os.getcwd()
34+
schema_key = 'org.label-schema.name'
35+
schema_name = 'config-controller'
36+
for c in self.docker_client.containers.list(filters={
37+
"label": "{}={}".format(
38+
schema_key, schema_name)}):
39+
networks = [k for k in c.attrs['NetworkSettings']['Networks']]
40+
logger.info("Networks %s", networks)
41+
if len(networks) > 0:
42+
self.docker_network = networks[0]
43+
if not self.docker_network:
44+
self.docker_network = 'bridge'
45+
logger.warn("WARNING: Use default network: %s",
46+
self.docker_network)
47+
48+
def get_containers(self, label):
49+
"""returns containers by label"""
50+
key, value = label.split('=')
51+
return [dict(
52+
addr=get_ipaddr(c),
53+
name=c.attrs['Name'][1:],
54+
assigned=c.labels.get('assigned', ""),
55+
sessionID=c.labels.get('sessionID', "0"))
56+
for c in self.docker_client.containers.list(filters={
57+
"label": "{}={}".format(
58+
key.strip(), value.strip())})]
59+
60+
def get_labels(self, name):
61+
"""returns container labels by name"""
62+
try:
63+
container = self.docker_client.containers.get(name)
64+
return container.labels
65+
except docker.errors.NotFound as e:
66+
pass
67+
return {}
68+
69+
def find_unassigned_containers(self, labels):
70+
return [c for c in self.docker_client.containers.list(
71+
filters={
72+
"label": [f'{k}={labels[k]}' for k in labels.keys()]})
73+
if not c.labels.get('assigned', "")]
74+
75+
def create_container(self, num_idling_containers, nameprefix,
76+
manifest, sessionID):
77+
prefix = ''
78+
if self.docker_network != 'bridge':
79+
prefix = self.docker_network.split('_')[0]+'_'
80+
image_name = manifest['spec']['containers'][0]['image']
81+
c = manifest['spec']['containers'][0]
82+
env = ['{0}={1}'.format(e['name'], e['value']) for e in c['env']]
83+
volumes = ['{0}/{1}:{2}'.format(self.basedir, v['name'], v['mount'])
84+
for v in c['volumes']]
85+
logger.info("Volumes %s", volumes)
86+
if sessionID != '0':
87+
assign_ts = str(int(time.time()))
88+
else:
89+
assign_ts = ''
90+
91+
#uc = self.find_unassigned_containers(manifest['metadata']['labels'])
92+
# if len(uc)-1 < num_idling_containers:
93+
container_name = prefix+nameprefix+'-'+uuid.uuid4().hex[:8]
94+
self.docker_client.containers.run(
95+
image_name, name=container_name,
96+
labels={'app': nameprefix,
97+
'sessionID': sessionID,
98+
'assigned': assign_ts},
99+
detach=True, environment=env,
100+
volumes=volumes,
101+
network=self.docker_network, # remove=True, ???
102+
restart_policy={"Name": "on-failure",
103+
"MaximumRetryCount": 5})
104+
logger.info("Started container %s on network %s", container_name,
105+
self.docker_network)
106+
c = self.docker_client.containers.list(
107+
filters={"name": container_name})[0]
108+
# if uc:
109+
# c = uc[0]
110+
# c.labels['sessionID'] = sessionID
111+
return dict(name=container_name, addr=get_ipaddr(c))
112+
113+
def delete_container(self, num_idling_containers, name):
114+
try:
115+
logger.info("removing container %s", name)
116+
# self.docker_client.containers.get(name).stop()
117+
self.docker_client.containers.get(name).remove(force=True)
118+
#logger.info("delete %s ignored")
119+
return dict(status='OK', message='{} deleted'.format(name))
120+
except docker.errors.NotFound as e:
121+
logger.warning('delete container %s: %s', name, str(e))
122+
return dict(status='NotFound')

0 commit comments

Comments
 (0)