Merge ~dannf/charms/+source/scalebot-jenkins:go-stop into ~dannf/charms/+source/scalebot-jenkins:master

Proposed by dann frazier
Status: Superseded
Proposed branch: ~dannf/charms/+source/scalebot-jenkins:go-stop
Merge into: ~dannf/charms/+source/scalebot-jenkins:master
Diff against target: 1007 lines (+919/-2)
14 files modified
.flake8 (+2/-0)
.pre-commit-config.yaml (+10/-0)
README.devel (+12/-0)
README.md (+92/-0)
actions.yaml (+2/-0)
actions/refreshjobs (+6/-0)
bin/jobsync.py (+100/-0)
bin/pull-and-reload-jobs (+18/-0)
config.yaml (+47/-0)
files/known_hosts.lp (+2/-0)
layer.yaml (+22/-1)
lib/charms/layer/scalebot_jenkins.py (+44/-0)
metadata.yaml (+12/-1)
reactive/scalebot-jenkins.py (+550/-0)
Reviewer Review Type Date Requested Status
dann frazier Pending
Review via email: mp+401252@code.launchpad.net
To post a comment you must log in.

Unmerged commits

13888a4... by dann frazier

Block unnecessary reactions when the charm is stopping

If a user removes the scalebot application (`juju remove-application`), juju
will trigger the stop hook. We provide a stop hook which destroys the lab
controller, freeing up resources in the cloud. However, the charm continues
to react to other events, which can lead to it *re*-bootstrapping a
controller. This can lead to orphaned resources in the cloud.

Introduce a new pair of states[*], "scalebot.go" and "scalebot.stop", which
are intended to be mutually exclusive. The charm will start out and remain
in the "scalebot.go" state until the stop hook is called, at which point
we'll transition to the "scalebot.stop" state. Add guards to prevent
various reactions from executing in "scalebot.stop".

[*] The "state" API has been apparently deprecated for a "flag" API for
    some time. We should switch over to the "flag" API - but I'll leave
    that for another time.

79a5200... by dann frazier

Fix typo and simplify log message

The main goal here is to correct spelling of "bootstrapping", but I also
decided to shorten the message because I'm not sure the second sentence
was adding to the first.

5f7aec1... by dann frazier

Fix reference-before-assignment issue

Seen in debug-log:

unit-scalebot-0: 12:01:15 WARNING unit.scalebot/0.stop UnboundLocalError: local variable 'modeldefaultsfile' referenced before assignment
unit-scalebot-0: 12:01:16 ERROR juju.worker.uniter.operation hook "stop" (via explicit, bespoke hook script) failed: exit status 1

fe90de4... by dann frazier

Add focal support

This charm deploys fine on Ubuntu 20.04 ('focal'), so designate it to
be a supported series.

5e2ad36... by dann frazier

Remove the python3-maas-client dependency

The scalebot-jenkins charm itself does not know anything about MAAS, it is
cloud-agnostic by design. If a specific scalebot config needs to talk to
a MAAS directly, it should install the necessary dependencies itself
(e.g. using scalebot.d/init). python3-maas-client is no longer available
in Ubuntu 20.04 ('focal'), so this is necessary to support that series.

1bd653d... by Taihsiang Ho

Merge branch 'mr-remote-lxd' into master

1454aa4... by Taihsiang Ho

refactor(config.yaml): rm useless lxd parameters

676404c... by Taihsiang Ho

feat: integrate juju yaml of remote lxd

By integrating the clouds.yaml and credentials.yaml for remote lxd, we could
merge the bootstraping methods of remote lxd and maas clouds.

BREAKING CHANGE: LP merge request: #391092

72c3e58... by Taihsiang Ho

feat(log): provide more explicit failure messages for debugging of importjenkins

ca70db6... by Taihsiang Ho

style(pre-commit): style fix

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/.flake8 b/.flake8
2new file mode 100644
3index 0000000..b5039a2
4--- /dev/null
5+++ b/.flake8
6@@ -0,0 +1,2 @@
7+[flake8]
8+ignore = W503, E501
9diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
10new file mode 100644
11index 0000000..5f7fa9a
12--- /dev/null
13+++ b/.pre-commit-config.yaml
14@@ -0,0 +1,10 @@
15+repos:
16+ - repo: https://github.com/psf/black
17+ rev: stable
18+ hooks:
19+ - id: black
20+ language_version: python3.8
21+ - repo: https://gitlab.com/pycqa/flake8
22+ rev: master
23+ hooks:
24+ - id: flake8
25diff --git a/README.devel b/README.devel
26new file mode 100644
27index 0000000..fbaa6ff
28--- /dev/null
29+++ b/README.devel
30@@ -0,0 +1,12 @@
31+# Pushing changes to the charm store
32+```
33+$ charm build
34+$ charm push /tmp/charm-builds/scalebot-jenkins cs:~ce-hyperscale/scalebot-jenkins
35+```
36+This will return a URL with a new version, e.g.:
37+url: cs:~ce-hyperscale/scalebot-jenkins-7
38+Use that new URL in the following commands:
39+```
40+$ charm release <url>
41+$ charm grant <url> everyone
42+```
43diff --git a/README.md b/README.md
44new file mode 100644
45index 0000000..43e5e2f
46--- /dev/null
47+++ b/README.md
48@@ -0,0 +1,92 @@
49+# What is ScaleBot?
50+ScaleBot is a framework for running scheduled jobs against a cloud. It is built upon the Jenkins CI and Juju modeling tools.
51+
52+# QuickStart (using Amazon Web Services)
53+Clone a local copy of the sample ScaleBot configuration repository:
54+```
55+$ git clone git://git.launchpad.net/~scalebot-team/+git/scalebot-config
56+```
57+Make a copy of the aws credentials template, and add your account keys:
58+```
59+$ cd scalebot-config
60+$ cp labs/aws/credentials.yaml.template labs/aws/credentials.yaml
61+$ vi labs/aws/credentials.yaml
62+```
63+Deploy the ScaleBot bundle. In this example, we'll deploy to local LXD containers:
64+```
65+$ juju bootstrap localhost
66+Creating Juju controller "localhost-localhost" on localhost/localhost
67+Looking for packaged Juju agent version 2.2.4 for amd64
68+To configure your system to better support LXD containers, please see: https://github.com/lxc/lxd/blob/master/doc/production-setup.md
69+Launching controller instance(s) on localhost/localhost...
70+ - juju-689661-0 (arch=amd64)
71+Fetching Juju GUI 2.9.2
72+Waiting for address
73+Attempting to connect to 10.98.200.35:22
74+Bootstrap agent now started
75+Contacting Juju controller at 10.98.200.35 to verify accessibility...
76+Bootstrap complete, "localhost-localhost" controller now available.
77+Controller machines are in the "controller" model.
78+Initial model "default" added.
79+$ juju deploy bundle.yaml
80+Deploying charm "cs:xenial/jenkins-4"
81+Deploying charm "cs:~ce-hyperscale/scalebot-jenkins-3"
82+Related "jenkins:extension" and "scalebot:extension"
83+Deploy of bundle completed.
84+```
85+Once deployment has completed, use ```juju status``` to find the IP of your scalebot jenkins server. You can connect to it at http://ip-of-server:8080. The sample configuration includes an example "Hello World" job. Building this job will deploy a node in the Lab cloud and run ```echo "Hello World"``` on it.
86+
87+# Architecture
88+ScaleBot is deployed from a Juju charm that builds a running instance of Jenkins CI, initialized with a git repository that provides job definitions and a target cloud to run those jobs against. We refer to this target cloud as a "lab". ScaleBot will use the target cloud ("lab") configuration to automatically setup an *internal* Juju controller which Jenkins jobs can use to deploy systems and workloads as necessary within the lab.
89+
90+<!-- To edit this image, modify the following image and select
91+ "Publish to the web..."
92+ https://docs.google.com/drawings/d/1BPKUyUMAy8PfAf81397eAKD8shFQT3D9uuqDW4yfRCg/edit -->
93+
94+![alt text](https://docs.google.com/drawings/d/e/2PACX-1vS0RwIg_CyMTKzJlnRJTf9wrH4e4v3W8wbMsI4RwEcB1FuvclU8Q7X4NPVOLSzYWtGBcXERMVI-PY7p/pub?w=935&h=527 "ScaleBot Diagram A")
95+
96+Of course, since scalebot-jenkins is a charm itself, it also is deployed in a cloud. This can just be a LXD cloud though, as the controller and Jenkins server can easily run in containers. The important thing is that the jenkins server in the ScaleBot cloud is on a network that can access the Lab cloud.
97+
98+<!-- To edit this image, modify the following image and select
99+ "Publish to the web..."
100+ https://docs.google.com/drawings/d/1mb8oea6fmZtTmnC-gpMZgmsGnsaMPaO4XELZv0jNRFo/edit -->
101+
102+![alt text](https://docs.google.com/drawings/d/e/2PACX-1vRNzqlTKIU57N-cdSA1L8M-20zxUbwMxteOAQEs321NrER-EkSEhbb1P-qZc8V_p-dUq1lTmsTl5IHq/pub?w=1016&h=508 "ScaleBot Diagram B")
103+
104+# Configuration Repository
105+The scalebot-jenkins charm accepts configuration that tells ScaleBot where to fetch Jenkins job and Lab Cloud definitions. This data must be in a git repository with the following layout:
106+
107+```
108+jobs/<jobfoo>.yaml
109+jobs/<jobbar>.yaml
110+...
111+jobs/<jobbaz>.yaml
112+
113+labs/<labname>/<labname>.yaml
114+labs/<labname>/clouds.yaml
115+labs/<labname>/credentials.yaml
116+labs/<labname>/model-defaults.yaml
117+scalebot.d/init
118+scalebot.d/refresh
119+```
120+
121+Jobs must be in the YAML format used by [Jenkins Job Builder](https://docs.openstack.org/infra/jenkins-job-builder), as that is the tool used to load them into Jenkins. The clouds.yaml, credentials.yaml, and model-defaults.yaml files are passed directly to Juju when bootsrapping the Lab controller, so see [Juju documentation](http://jujucharms.com/docs) for details on their contents.
122+
123+You may also want to store code in this repository and call it from your jobs.
124+
125+
126+# Job Environment
127+ScaleBot will automatically create an empty model prior to building a jenkins job. This model will have a unique name, based on the $BUILD_ID. Jobs can assume that this model exists and is in-focus prior to executing, so your build code can just begin deploying charms immediately on startup.
128+
129+Since it is expected that builds will deploy systems using Juju, the python [bindings for juju](https://github.com/juju/python-libjuju) are pre-installed on the system.
130+
131+## Environment Variables
132+In addition to the environment variables set by Jenkins, the following variable is also available:
133+
134+**$SCALEBOT_REPO** will be set to the path of the locally cloned copy of the Scalebot configuration repository.
135+
136+## scalebot.d/init hook
137+If it exists, ```$SCALEBOT_REPO/scalebot.d/init``` will be executed each time the configuration repository is refreshed. You can use this to install any additional dependencies needed for your jobs.
138+
139+## scalebot.d/refresh hook
140+If it exists, ```$SCALEBOT_REPO/scalebot.d/refresh``` will be executed after the repository is updated, and before loading jobs. This is useful in case something job yaml files need to be dynamically generated.
141diff --git a/actions.yaml b/actions.yaml
142new file mode 100644
143index 0000000..6d6949f
144--- /dev/null
145+++ b/actions.yaml
146@@ -0,0 +1,2 @@
147+refreshjobs:
148+ descripton: Perform a Git Pull and Refresh the Jenkins Jobs if any.
149diff --git a/actions/refreshjobs b/actions/refreshjobs
150new file mode 100755
151index 0000000..21e3870
152--- /dev/null
153+++ b/actions/refreshjobs
154@@ -0,0 +1,6 @@
155+#!/bin/sh
156+
157+set -e
158+set -x
159+
160+su - jenkins -c "${JUJU_CHARM_DIR}/bin/pull-and-reload-jobs"
161diff --git a/bin/jobsync.py b/bin/jobsync.py
162new file mode 100755
163index 0000000..108c8ac
164--- /dev/null
165+++ b/bin/jobsync.py
166@@ -0,0 +1,100 @@
167+#!/usr/bin/env python3
168+import argparse
169+import os
170+import logging
171+import subprocess
172+import sys
173+
174+SCALEBOT_HOME = "/srv/scalebot"
175+SCALEBOT_REPO = os.path.join(SCALEBOT_HOME, "repo")
176+
177+
178+def syncjobs(JOBSET):
179+ jjb = "/usr/local/bin/jenkins-jobs"
180+ # Load jenkinsjobs config
181+ config_ini = "/etc/jenkins_jobs/jenkins_jobs.ini"
182+ # common directory to add jjb Macro
183+ COMMON = SCALEBOT_REPO + "/jobs/common"
184+ cmd = [jjb, "--flush-cache", "--conf", config_ini, "update"]
185+ flag = []
186+ if os.path.exists(COMMON):
187+ jobs = "{}/jobs/{}:{}".format(SCALEBOT_REPO, JOBSET, COMMON)
188+ flag = ["-r", jobs]
189+ else:
190+ jobs = "{}/jobs/{}".format(SCALEBOT_REPO, JOBSET)
191+ flag = [jobs]
192+ subprocess.run(cmd + flag, check=True)
193+
194+
195+def main():
196+
197+ parser = argparse.ArgumentParser()
198+
199+ parser.add_argument(
200+ "-s",
201+ "--syncjobs",
202+ help=(
203+ "This will import jenkins jobs via yaml from \
204+ $SCALEBOT_REPO/jobs/"
205+ ),
206+ required=False,
207+ action="store_true",
208+ )
209+ parser.add_argument(
210+ "-d",
211+ "--deletejobs",
212+ help=(
213+ "This will Delete all jobs from Hudson, useful \
214+ or refreshing job list after branch change."
215+ ),
216+ required=False,
217+ action="store_true",
218+ )
219+ parser.add_argument(
220+ "-j",
221+ "--jobset",
222+ help=(
223+ "Name of the desired jobset from which to import \
224+ $SCALEBOT_REPO/jobs/$JOBSET"
225+ ),
226+ required=True,
227+ type=str,
228+ )
229+
230+ args = parser.parse_args()
231+
232+ # If no args specified upon calling tool, dump help and exit
233+ if len(sys.argv[1:]) == 0:
234+ parser.print_help()
235+ parser.exit()
236+
237+ try:
238+ if args.deletejobs:
239+ # Delete the jobs completely from Jenkins
240+ logger.info("Removing Jenkins Jobs from Hudson")
241+ cmd = subprocess.Popen(
242+ ["jenkins-jobs", "delete-all"],
243+ stdin=subprocess.PIPE,
244+ stdout=subprocess.PIPE,
245+ )
246+ cmd.communicate(input="Y".encode())
247+ logger.info("Hudson Jobs Deleted")
248+
249+ if args.syncjobs:
250+ JOBSET = args.jobset
251+ logger.info("Importing Jenkins Jobs")
252+ syncjobs(JOBSET)
253+
254+ logger.info("Import Jobs Complete")
255+
256+ except Exception as e:
257+ logger.info("Problem with job import")
258+ logger.info(e)
259+ return "-1"
260+
261+
262+logging.basicConfig(level=logging.INFO)
263+logger = logging.getLogger(__name__)
264+
265+if __name__ == "__main__":
266+ sys.exit(main())
267diff --git a/bin/pull-and-reload-jobs b/bin/pull-and-reload-jobs
268new file mode 100755
269index 0000000..330f824
270--- /dev/null
271+++ b/bin/pull-and-reload-jobs
272@@ -0,0 +1,18 @@
273+#!/bin/sh
274+
275+# Pull the git repo and refresh jobs
276+
277+set -e
278+set -x
279+
280+[ -n "$SCALEBOT_HOME" ]
281+[ -n "$SCALEBOT_JOBSET" ]
282+[ -n "$SCALEBOT_REPO" ]
283+
284+git -C ${SCALEBOT_REPO} pull --ff-only
285+refresh="${SCALEBOT_REPO}/scalebot.d/refresh"
286+if [ -x "$refresh" ]; then
287+ $refresh
288+fi
289+jobsync="${SCALEBOT_HOME}/bin/jobsync.py"
290+${jobsync} -j ${SCALEBOT_JOBSET} -s
291diff --git a/config.yaml b/config.yaml
292new file mode 100644
293index 0000000..98588c7
294--- /dev/null
295+++ b/config.yaml
296@@ -0,0 +1,47 @@
297+options:
298+ scalebot_private_ssh_key:
299+ description: A null-passphrase SSH private key with access to $scalebot_repo
300+ type: "string"
301+ default:
302+ scalebot_config_repo:
303+ description: URI to a ScaleBot config git repository
304+ type: "string"
305+ default: git://git.launchpad.net/~scalebot-team/+git/scalebot-config
306+ scalebot_config_branch:
307+ description: The branch of $scalebot_repo to checkout
308+ type: "string"
309+ default: master
310+ scalebot_juju_clouds:
311+ description: Juju cloud config YAML for target cloud
312+ type: "string"
313+ default:
314+ scalebot_juju_credentials:
315+ description: Credentials YAML for Juju cloud defined in scalebot_juju_clouds
316+ type: "string"
317+ default:
318+ scalebot_juju_model_defaults:
319+ description: Juju model defaults configuration YAML
320+ type: "string"
321+ default:
322+ scalebot_juju_bootstrap_constraints:
323+ description: Bootstrap constraints for Juju controller
324+ type: "string"
325+ default:
326+ scalebot_lab_type:
327+ description: Bootstrap lab type for Juju controller
328+ type: "string"
329+ default: maas
330+ scalebot_jobset:
331+ description: Desired jobset to import jenkins jobs from
332+ type: "string"
333+ default:
334+ scalebot_production:
335+ description: Is this a production (vs. a development) deployment? When set to true, tests will find SCALEBOT_PRODUCTION=1 in their environment. This gives tests an option to behave differently in production vs. development environments.
336+ type: "boolean"
337+ default: false
338+ install_sources:
339+ default: |
340+ - ppa:juju/stable
341+ install_keys:
342+ default: |
343+ - null
344diff --git a/files/known_hosts.lp b/files/known_hosts.lp
345new file mode 100644
346index 0000000..6c51a1e
347--- /dev/null
348+++ b/files/known_hosts.lp
349@@ -0,0 +1,2 @@
350+|1|dHt6eHIAjzK73nzI0mQEboOUmsg=|dngXa3BXrbOi2e8ATMU9VUD6A9E= ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDEFREwBD2ye2Xrc2SVcUmmJ44MF1BCB3W11NTaiqzVj7XZnQmgWZk9UadHVY2wBXvelcDO51MPN5ozJjFAknw09rP7XMRJMlAOLSIVoU6DRF1u1j8kJVY+dfiDHheS7+siADnrmb8HGn2xQQ6EJDjAXrw1x58x5eZjQ0PFWdI+pRTdYGvWkpHdXKFO6a9/lDx4uo9MCnePEGi/QnkCmKqLCBUlYNZYRiB8nVee2tMF0mjV8xk1rJ+/UP+897+FXFR9w/B1EPRjiQ35ZNQZKPP4isxPtyMuCQkZY7ckWr5YsylNfvNcyGDnO1XazZhJ71rzDpi1RmnFXBW5i+2dm2y7
351+|1|CWLqG45/xn+VEnXUvak6ST5YuiQ=|ZwtFngsyHwu3XLlLeu/JGjQijis= ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDEFREwBD2ye2Xrc2SVcUmmJ44MF1BCB3W11NTaiqzVj7XZnQmgWZk9UadHVY2wBXvelcDO51MPN5ozJjFAknw09rP7XMRJMlAOLSIVoU6DRF1u1j8kJVY+dfiDHheS7+siADnrmb8HGn2xQQ6EJDjAXrw1x58x5eZjQ0PFWdI+pRTdYGvWkpHdXKFO6a9/lDx4uo9MCnePEGi/QnkCmKqLCBUlYNZYRiB8nVee2tMF0mjV8xk1rJ+/UP+897+FXFR9w/B1EPRjiQ35ZNQZKPP4isxPtyMuCQkZY7ckWr5YsylNfvNcyGDnO1XazZhJ71rzDpi1RmnFXBW5i+2dm2y7
352diff --git a/layer.yaml b/layer.yaml
353index 7623e14..413895f 100644
354--- a/layer.yaml
355+++ b/layer.yaml
356@@ -1 +1,22 @@
357-includes: ['layer:jenkins-charm']
358+includes:
359+ - layer:basic
360+ - layer:apt
361+ - interface:jenkins-extension
362+repo: git+ssh://git.launchpad.net/~ce-hyperscale/charms/+source/scalebot-jenkins
363+options:
364+ basic:
365+ use_venv: false
366+ python_packages:
367+ - python-jenkins==0.4.15
368+ - jenkins-job-builder
369+ - async-timeout
370+ - juju
371+ packages:
372+ - libssl-dev
373+ - libffi-dev
374+ - python3-cffi
375+ - python3-nacl
376+ apt:
377+ packages:
378+ - git
379+ - juju
380diff --git a/lib/charms/layer/scalebot_jenkins.py b/lib/charms/layer/scalebot_jenkins.py
381new file mode 100644
382index 0000000..6a2d81f
383--- /dev/null
384+++ b/lib/charms/layer/scalebot_jenkins.py
385@@ -0,0 +1,44 @@
386+from subprocess import CalledProcessError
387+from subprocess import check_call
388+
389+
390+__all__ = ["get_bootstrap_failure_hint"]
391+
392+
393+def get_bootstrap_failure_hint(msg):
394+ """
395+ Check input message and return a guess of the root cause of error.
396+
397+ Parameters
398+ ----------
399+ msg : string
400+ A message string. It is often stdout or stderr returned by a subprocess command.
401+
402+ Returns
403+ -------
404+ string
405+ The description of possible failure root cause.
406+
407+ """
408+ rtn_msg_prefix = "Failed for:"
409+ if (
410+ "cannot start bootstrap instance: failed to acquire node" in msg
411+ and "No such tag" in msg
412+ ):
413+ return f"{rtn_msg_prefix} not finding nodes with specified tags. You may want to check your bootstrap constraint."
414+ elif "bootstrap instance started but did not change to Deployed state" in msg:
415+ return f"{rtn_msg_prefix} bootstrap instance started but did not change to Deployed state."
416+ elif "failed to acquire node: No available machine matches constraints.":
417+ return f"{rtn_msg_prefix} failed to acquire node: No available machine matches constraints."
418+ else:
419+ return f"{rtn_msg_prefix} unknown reason."
420+
421+
422+def if_controller():
423+ cmd = ["/bin/su", "-", "jenkins", "-c", "juju controllers"]
424+ try:
425+ check_call(cmd)
426+ return True
427+
428+ except CalledProcessError:
429+ return False
430diff --git a/metadata.yaml b/metadata.yaml
431index b57c212..7451207 100644
432--- a/metadata.yaml
433+++ b/metadata.yaml
434@@ -1,5 +1,16 @@
435 name: scalebot-jenkins
436 summary: Automation Server for ScaleBot
437-maintainer: dann frazier <dann.frazier@canonical.com>
438+maintainer: canonical-hyperscale-team@lists.canonical.com
439 description: |
440 Automates jobs in a ScaleBot Lab.
441+tags:
442+ - ops
443+series:
444+ - focal
445+ - bionic
446+subordinate: true
447+requires:
448+ extension:
449+ interface: jenkins-extension
450+ scope: container
451+
452diff --git a/reactive/scalebot-jenkins.py b/reactive/scalebot-jenkins.py
453new file mode 100644
454index 0000000..9a8a2b6
455--- /dev/null
456+++ b/reactive/scalebot-jenkins.py
457@@ -0,0 +1,550 @@
458+import os
459+import shutil
460+import stat
461+import subprocess
462+import tempfile
463+import requests
464+import jenkins
465+import io
466+import yaml
467+
468+from subprocess import CalledProcessError
469+from charmhelpers.core import hookenv
470+from charmhelpers.core.host import service_restart
471+from charmhelpers.core.hookenv import (
472+ log,
473+ status_set,
474+ ERROR,
475+ WARNING,
476+ DEBUG,
477+)
478+from charms.reactive import (
479+ hook,
480+ when,
481+ when_all,
482+ when_none,
483+ when_not,
484+ set_state,
485+ get_flags,
486+ remove_state,
487+)
488+from charms.reactive.helpers import data_changed
489+from charms.layer import scalebot_jenkins
490+
491+SCALEBOT_URL = "http://localhost:8080"
492+SCALEBOT_HOME = "/srv/scalebot"
493+SCALEBOT_REPO = os.path.join(SCALEBOT_HOME, "repo")
494+PYTHONPATH = os.path.join(SCALEBOT_REPO, "lib")
495+
496+
497+def _run_cmd_yaml(args):
498+ """Runs a command, assuming it will send some valid YAML data to stdout,
499+ and parses its output. All the output will be loaded in memory.
500+ """
501+
502+ proc = subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
503+ try:
504+ rstdout, rstderr = proc.communicate(timeout=15)
505+ except subprocess.TimeoutExpired as exc:
506+ proc.kill()
507+ raise exc
508+ else:
509+ if proc.returncode != 0:
510+ raise Exception("Command error: " + str(rstderr))
511+ return yaml.load(io.BytesIO(rstdout), Loader=yaml.SafeLoader)
512+
513+
514+@when_not("scalebot.jjb.workaround.installed")
515+def scalebot_install_jjb_workaround():
516+ cfgfile = "/etc/default/jenkins"
517+ tmpfile = "%s.scalebot.tmp" % (cfgfile)
518+
519+ snippet = [
520+ "# ScaleBot Workaround for https://storyboard.openstack.org/#!/story/2006489\n",
521+ 'JAVA_ARGS+=" -Dhudson.security.csrf.DefaultCrumbIssuer.EXCLUDE_SESSION_ID=true"\n',
522+ ]
523+
524+ with open(cfgfile, "r") as f:
525+ cfg = f.readlines()
526+ with open(tmpfile, "w+") as f:
527+ for line in cfg:
528+ if line in snippet:
529+ continue
530+ f.write("%s" % (line))
531+ f.writelines(snippet)
532+ shutil.copymode(cfgfile, tmpfile)
533+ os.rename(tmpfile, cfgfile)
534+
535+ # Do a full service restart for Jenkins. We can't do a simple safe
536+ # restart yet because we need this workaround to connect to the API.
537+ # We only can do this on deployment, otherwise jobs can be disrupted.
538+ service_restart("jenkins")
539+
540+ set_state("scalebot.jjb.workaround.installed")
541+
542+
543+@when_all("scalebot.go", "extension.available")
544+def configure_jenkins_jobbuilder(extension):
545+ remove_state("scalebot.configured.jobbuilder")
546+ status_set("maintenance", "Configuring JobBuilder")
547+ if not os.path.isdir("/etc/jenkins_jobs"):
548+ os.mkdir("/etc/jenkins_jobs")
549+
550+ jj_ini = "/etc/jenkins_jobs/jenkins_jobs.ini"
551+ if os.path.isfile(jj_ini):
552+ os.remove(jj_ini)
553+
554+ config = extension.get_connection_info()
555+ username = config["admin_username"]
556+ password = config["admin_password"]
557+
558+ jenkinsini = open(jj_ini, "w")
559+ jenkinsini.write(
560+ """[jenkins]\nuser=%s\n
561+password=%s
562+url=%s
563+"""
564+ % (username, password, SCALEBOT_URL)
565+ )
566+ jenkinsini.close()
567+
568+ status_set(
569+ "maintenance", "Configured JobBuilder. Waiting for Scalebot Juju controller."
570+ )
571+
572+ log(scalebot_jenkins.if_controller(), DEBUG)
573+
574+ if scalebot_jenkins.if_controller():
575+ status_set(
576+ "maintenance", "Configured JobBuilder and found Scalebot Juju controllers."
577+ )
578+ set_state("scalebot.configured.jobbuilder")
579+ log("scalebot.configured.jobbuilder is set.")
580+
581+ flags = get_flags()
582+ log(f"current flags: {flags}", DEBUG)
583+ else:
584+ status_set(
585+ "waiting", "Configured JobBuilder. Waiting for Scalebot Juju controller."
586+ )
587+
588+
589+@when("extension.changed")
590+def unconfigure_jobbuilder():
591+ remove_state("scalebot.configured.jobbuilder")
592+
593+
594+@hook("config-changed")
595+def configure_scalebot_env():
596+ config = hookenv.config()
597+ jobset = config.get("scalebot_jobset")
598+ sbprofile = "/etc/profile.d/scalebot.sh"
599+
600+ with open(sbprofile, "w") as f:
601+ f.write("#Export Scalebot required paths\n")
602+ f.write("export PYTHONPATH=%s\n" % (PYTHONPATH))
603+ f.write('export SCALEBOT_HOME="%s"\n' % (SCALEBOT_HOME))
604+ f.write('export SCALEBOT_REPO="%s"\n' % (SCALEBOT_REPO))
605+ f.write('export SCALEBOT_JOBSET="%s"\n' % (jobset))
606+ prod = int(config.get("scalebot_production"))
607+ f.write("export SCALEBOT_PRODUCTION=%d\n" % (prod))
608+
609+ # We need to restart to get this environment.
610+ remove_state("scalebot.configured.env")
611+ set_state("scalebot.written.env")
612+
613+
614+def scalebot_link_files_into_home():
615+ # Link files used outside of charm context into $SCALEBOT_HOME
616+ src = os.path.join(hookenv.charm_dir(), "bin")
617+ dest = os.path.join(SCALEBOT_HOME, "bin")
618+ if os.path.islink(dest):
619+ os.unlink(dest)
620+ os.symlink(src, dest)
621+
622+
623+def scalebot_git_config_check():
624+ required_configs = ["scalebot_config_repo", "scalebot_config_branch"]
625+ missing_configs = []
626+ config = hookenv.config()
627+ for c in required_configs:
628+ if not config.get(c):
629+ missing_configs.append(c)
630+ if len(missing_configs) > 0:
631+ status_set(
632+ "blocked",
633+ "Unable to continue due to missing configs: %s"
634+ % ", ".join(missing_configs),
635+ )
636+ return False
637+ return True
638+
639+
640+@when_all("scalebot.go", "apt.installed.git")
641+def scalebot_git_clone():
642+ if not scalebot_git_config_check():
643+ return
644+
645+ config = hookenv.config()
646+ repo = config.get("scalebot_config_repo")
647+ branch = config.get("scalebot_config_branch")
648+ ssh_key = config.get("scalebot_private_ssh_key")
649+
650+ if not data_changed("scalebot.git.config", [repo, branch, ssh_key]):
651+ log("Repo config has not changed, nothing to do.")
652+ return True
653+
654+ log("Scalebot repo config has changed, (re-)cloning.")
655+ remove_state("scalebot.configured.repo")
656+ status_set("maintenance", "Cloning Scalebot repository")
657+
658+ try:
659+ shutil.rmtree(SCALEBOT_HOME)
660+ except FileNotFoundError:
661+ pass
662+
663+ os.makedirs(SCALEBOT_HOME)
664+ shutil.chown(SCALEBOT_HOME, user="jenkins", group="jenkins")
665+ scalebot_link_files_into_home()
666+
667+ known_hosts = os.path.join(hookenv.charm_dir(), "files", "known_hosts.lp")
668+
669+ git_ssh = ""
670+ keypath = os.path.join(SCALEBOT_HOME, "key")
671+ if ssh_key:
672+ log("Writing ssh key to %s" % (keypath))
673+ with os.fdopen(
674+ os.open(keypath, os.O_WRONLY | os.O_CREAT, mode=0o600), "w"
675+ ) as keyfile:
676+ shutil.chown(keypath, user="jenkins", group="jenkins")
677+ keyfile.write(ssh_key)
678+ ssh_cmd = "/usr/bin/ssh -v -i %s -o UserKnownHostsFile=%s" % (
679+ keypath,
680+ known_hosts,
681+ )
682+ git_ssh = '-c core.sshCommand="%s"' % (ssh_cmd)
683+ else:
684+ try:
685+ os.unlink(keypath)
686+ except FileNotFoundError:
687+ pass
688+
689+ git_cmd = "/usr/bin/git clone %s -b %s %s %s" % (
690+ git_ssh,
691+ branch,
692+ repo,
693+ os.path.join(SCALEBOT_HOME, "repo"),
694+ )
695+
696+ su_git_cmd = ["/bin/su", "-", "jenkins", "-c", git_cmd]
697+ log("Beginning git clone")
698+ log(su_git_cmd)
699+ subprocess.run(su_git_cmd, check=True)
700+
701+ set_state("scalebot.configured.repo")
702+ remove_state("scalebot.repo.init")
703+
704+
705+@when_not("scalebot.repo.init", "scalebot.stop")
706+def scalebot_repo_init():
707+ init_path = os.path.join(SCALEBOT_REPO, "scalebot.d", "init")
708+ if os.path.exists(init_path):
709+ status_set("maintenance", "Executing %s" % (init_path))
710+ subprocess.check_call(init_path)
711+ set_state("scalebot.repo.init")
712+
713+
714+@hook("config-changed")
715+def scalebot_git_reconfig():
716+ config = hookenv.config()
717+ required_configs = [
718+ "scalebot_private_ssh_key",
719+ "scalebot_config_repo",
720+ "scalebot_config_branch",
721+ ]
722+ if any(config.changed(key) for key in required_configs):
723+ scalebot_git_clone()
724+
725+
726+@when_all(
727+ "scalebot.configured.repo",
728+ "scalebot.configured.jobbuilder",
729+ "scalebot.configured.juju",
730+ "scalebot.go",
731+)
732+def scalebot_ready():
733+ status_set("active", "ScaleBot is Ready")
734+ log("Congratulations!! Scalebot is ready. Go testing go!!")
735+
736+
737+@when_all(
738+ "scalebot.configured.repo",
739+ "scalebot.configured.jobbuilder",
740+ "scalebot.configured.env",
741+ "scalebot.go",
742+ "scalebot.jjb.workaround.installed",
743+ "scalebot.jenkins-http-ready",
744+)
745+def importjenkins():
746+ cmd = os.path.join(hookenv.charm_dir(), "actions", "refreshjobs")
747+ log(cmd, DEBUG)
748+ try:
749+ subprocess.run(
750+ [cmd], check=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
751+ )
752+ except CalledProcessError as e:
753+ msg_stdout = e.stdout.decode()
754+ log(msg_stdout, level=ERROR)
755+
756+
757+@hook("config-changed")
758+def scalebot_juju_reconfig():
759+ config = hookenv.config()
760+ required_configs = [
761+ "scalebot_juju_clouds",
762+ "scalebot_juju_credentials",
763+ "scalebot_juju_model_defaults",
764+ ]
765+ if any(config.changed(key) for key in required_configs):
766+ scalebot_configure_juju()
767+
768+
769+@when_all("apt.installed.juju", "scalebot.go")
770+def scalebot_configure_juju():
771+ config = hookenv.config()
772+
773+ clouds = config.get("scalebot_juju_clouds")
774+ creds = config.get("scalebot_juju_credentials")
775+ modeldefaults = config.get("scalebot_juju_model_defaults")
776+ bootstrapconstraints = config.get("scalebot_juju_bootstrap_constraints")
777+
778+ if (
779+ not data_changed("scalebot.juju.config", [clouds, creds])
780+ and scalebot_jenkins.if_controller()
781+ ):
782+ log(
783+ "Juju controller is available and its config has not changed, nothing to do."
784+ )
785+
786+ return True
787+
788+ if data_changed("scalebot.juju.config", [clouds, creds]):
789+ log("Juju config has changed, (re-)bootstrapping.")
790+ elif not scalebot_jenkins.if_controller():
791+ log("Scalebot Juju controller is not ready, (re-)bootstrapping.")
792+ else:
793+ log("Scalebot Juju controller is going to be deployed, (re-)bootstrapping.")
794+
795+ remove_state("scalebot.configured.juju")
796+ status_set("maintenance", "Bootstrapping Juju controller")
797+
798+ # In case we're reconfiguring, destroy any pre-existing
799+ # controller before re-bootstrapping
800+ subprocess.run(
801+ ["/bin/su", "-", "jenkins", "-c", "juju destroy-controller jenkins -y"],
802+ check=False,
803+ )
804+
805+ scalebot_configure_juju_for_lab(clouds, creds, modeldefaults, bootstrapconstraints)
806+
807+
808+def scalebot_configure_juju_for_lab(clouds, creds, modeldefaults, bootstrapconstraints):
809+ if clouds:
810+ cloudsfile = tempfile.NamedTemporaryFile(mode="w", delete=False)
811+ cloudsfile.write(clouds)
812+ # Make it readable by user jenkins
813+ os.chmod(
814+ cloudsfile.name, stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP | stat.S_IROTH
815+ )
816+ cloudsfile.close()
817+ curr_clouds = _run_cmd_yaml(
818+ ["/bin/su", "-", "jenkins", "-c", "juju list-clouds --format yaml"]
819+ )
820+ if curr_clouds and ("scalebot" in curr_clouds):
821+ command = "juju update-cloud --client scalebot -f %s" % (cloudsfile.name)
822+ else:
823+ command = "juju add-cloud --client scalebot %s" % (cloudsfile.name)
824+ subprocess.run(["/bin/su", "-", "jenkins", "-c", command], check=True)
825+ os.unlink(cloudsfile.name)
826+
827+ if creds:
828+ credsfile = tempfile.NamedTemporaryFile(mode="w", delete=False)
829+ credsfile.write(creds)
830+ # Make it readable by user jenkins
831+ os.chmod(
832+ credsfile.name, stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP | stat.S_IROTH
833+ )
834+ credsfile.close()
835+ curr_creds = _run_cmd_yaml(
836+ ["/bin/su", "-", "jenkins", "-c", "juju show-credentials --format yaml"]
837+ )
838+ if (
839+ curr_creds
840+ and ("client-credentials" in curr_creds)
841+ and ("scalebot" in curr_creds["client-credentials"])
842+ ):
843+ command = "juju update-credential --client -f %s scalebot" % (
844+ credsfile.name
845+ )
846+ else:
847+ command = "juju add-credential --client -f %s scalebot" % (credsfile.name)
848+ subprocess.run(["/bin/su", "-", "jenkins", "-c", command], check=True)
849+ os.unlink(credsfile.name)
850+
851+ modeldefaultsparam = ""
852+ if modeldefaults:
853+ modeldefaultsfile = tempfile.NamedTemporaryFile(mode="w", delete=False)
854+ modeldefaultsfile.write(modeldefaults)
855+ os.chmod(
856+ modeldefaultsfile.name,
857+ stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP | stat.S_IROTH,
858+ )
859+ modeldefaultsfile.close()
860+ modeldefaultsparam = " --model-default %s" % (modeldefaultsfile.name)
861+
862+ bconstraints = ""
863+ if bootstrapconstraints:
864+ bconstraints = ' --bootstrap-constraints "%s"' % (bootstrapconstraints)
865+
866+ try:
867+ subprocess.run(
868+ [
869+ "/bin/su",
870+ "-",
871+ "jenkins",
872+ "-c",
873+ "juju bootstrap --debug scalebot jenkins"
874+ + modeldefaultsparam
875+ + bconstraints,
876+ ],
877+ check=True,
878+ stdout=subprocess.PIPE,
879+ stderr=subprocess.STDOUT,
880+ )
881+
882+ if modeldefaults:
883+ log(f"Removing {modeldefaultsfile.name}", DEBUG)
884+ os.unlink(modeldefaultsfile.name)
885+ log(f"Removed {modeldefaultsfile.name}", DEBUG)
886+
887+ log(
888+ "Bootstrapping of Scalebot Juju controller looks good."
889+ )
890+
891+ except CalledProcessError as e:
892+ msg_stdout = e.stdout.decode()
893+ log(msg_stdout, level=ERROR)
894+
895+ msg_guess = scalebot_jenkins.get_bootstrap_failure_hint(msg_stdout)
896+ msg = f"Fail to bootstrap a controller. It may be because: {msg_guess}"
897+ log(msg, level=ERROR)
898+
899+ # fail to bootstrap a controller is devastating
900+ # by setting status as "blocked" to help stop the deployment process
901+ status_set("blocked", msg)
902+
903+ if scalebot_jenkins.if_controller():
904+ set_state("scalebot.configured.juju")
905+
906+
907+@when_not("scalebot.stop")
908+def scalebot_start():
909+ set_state("scalebot.go")
910+
911+
912+@hook("stop")
913+def scalebot_stop():
914+ set_state("scalebot.stop")
915+ remove_state("scalebot.go")
916+ log("Stopping Scalebot.")
917+ status_set("maintenance", "Stopping ScaleBot")
918+
919+
920+@when("scalebot.stop")
921+def scalebot_juju_destroy_controller():
922+ status_set("maintenance", "Destroying Juju controller")
923+ subprocess.run(
924+ [
925+ "/bin/su",
926+ "-",
927+ "jenkins",
928+ "-c",
929+ "juju destroy-controller --destroy-all-models -y jenkins",
930+ ]
931+ )
932+
933+
934+@when_not("scalebot.jenkins-http-ready")
935+def jenkins_http_ready():
936+ rc = 500
937+ try:
938+ rc = requests.get(SCALEBOT_URL, timeout=5).status_code
939+ except (
940+ requests.HTTPError,
941+ requests.exceptions.Timeout,
942+ requests.exceptions.ConnectionError,
943+ ):
944+ pass
945+
946+ # Valid status codes (we can get redirects, login forms, etc.)
947+ if rc in (200, 300, 301, 302, 403):
948+ set_state("scalebot.jenkins-http-ready")
949+
950+
951+# Count the number of jobs currently running on Jenkins and lock it for new
952+# jobs if zero. This is required to prevent a race condition if new jobs are
953+# started between running this script and restarting the server.
954+
955+JK_RESTART_CHECK_SCRIPT = """
956+import jenkins.model.*
957+import hudson.model.*
958+
959+lst = Jenkins.instance.getView('All').getBuilds().findAll() {
960+ it.getResult().equals(null)
961+}
962+if (lst.size() == 0) {
963+ Jenkins.instance.doQuietDown();
964+}
965+println(lst.size());
966+"""
967+
968+
969+@when_none("scalebot.configured.env", "scalebot.stop")
970+@when_all(
971+ "scalebot.go",
972+ "scalebot.jenkins-http-ready",
973+ "scalebot.jjb.workaround.installed",
974+ "scalebot.written.env",
975+)
976+@when_all("extension.available", "scalebot.go")
977+def safe_jenkins_service_restart(extension):
978+ """Restart Jenkins service if no jobs are running.
979+
980+ Environment variables required by some jobs will not be available until
981+ we do a full service restart (i.e. finishing Java process and starting
982+ it again instead of just reloading Jenkins, as done by safe-restart).
983+
984+ Tests can be lost if we just restart so we need to get the number of
985+ running jobs (using the Rest API), restart the service and ensure that
986+ Jenkins is back online before proceeding.
987+ """
988+
989+ config = extension.get_connection_info()
990+ try:
991+ server = jenkins.Jenkins(
992+ SCALEBOT_URL,
993+ username=config["admin_username"],
994+ password=config["admin_password"],
995+ timeout=10,
996+ )
997+ ret = server.run_script(JK_RESTART_CHECK_SCRIPT)
998+ if ret and int(ret) == 0:
999+ log("Restarting Jenkins service")
1000+ service_restart("jenkins")
1001+ remove_state("scalebot.jenkins-http-ready")
1002+ set_state("scalebot.configured.env")
1003+ else:
1004+ log("Jenkins is running some jobs, will restart later")
1005+
1006+ except jenkins.JenkinsException as exc:
1007+ log("Jenkins API exception:" + str(exc), level=WARNING)

Subscribers

People subscribed via source and target branches

to all changes: